Testing a new UI

This commit is contained in:
Mukhtar Akere
2025-07-09 20:08:09 +01:00
parent dba5604d79
commit c72867ff57
25 changed files with 5826 additions and 3287 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,185 +1,141 @@
{{ 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 class="space-y-6">
<!-- Download Form -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<form id="downloadForm" enctype="multipart/form-data" class="space-y-3">
<!-- Torrent Input Section -->
<div class="space-y-2">
<div class="form-control">
<label class="label" for="magnetURI">
<span class="label-text font-semibold">
<i class="bi bi-magnet mr-2 text-primary"></i>Torrent Links
</span>
<span class="label-text-alt">Paste magnet links or URLs</span>
</label>
<textarea class="textarea textarea-bordered h-32 font-mono text-sm"
id="magnetURI"
name="urls"
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 class="divider">OR</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">
<i class="bi bi-file-earmark-arrow-up mr-2 text-secondary"></i>Upload Torrent Files
</span>
<span class="label-text-alt">Select .torrent files</span>
</label>
<input type="file"
class="file-input file-input-bordered w-full"
id="torrentFiles"
name="torrents"
multiple
accept=".torrent">
<div class="label">
<span class="label-text-alt">
<i class="bi bi-info-circle mr-1"></i>You can select multiple files at once
</span>
</div>
</div>
</div>
<hr />
<div class="divider"></div>
<div class="row mb-3">
<div class="col">
<label for="downloadAction" class="form-label">Post Download Action</label>
<select class="form-select" id="downloadAction" name="downloadAction">
<option value="symlink" selected>Symlink</option>
<option value="download">Download</option>
<option value="none">None</option>
<!-- Configuration Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="space-y-2">
<h3 class="text-lg font-semibold flex items-center">
<i class="bi bi-gear mr-2 text-info"></i>Download Settings
</h3>
<div class="form-control">
<label class="label" for="downloadAction">
<span class="label-text">Post Download Action</span>
</label>
<select class="select select-bordered" id="downloadAction" name="downloadAction">
<option value="symlink" selected>Create Symlink</option>
<option value="download">Download Files</option>
<option value="none">No Action</option>
</select>
<small class="text-muted">Choose how to handle the added torrent (Default to symlinks)</small>
</div>
<div class="col">
<label for="downloadFolder" class="form-label">Download Folder</label>
<input type="text" class="form-control" id="downloadFolder" name="downloadFolder" placeholder="Enter Download Folder (e.g /downloads/torrents)">
<small class="text-muted">Default is your qbittorent download_folder</small>
</div>
<div class="col">
<label for="arr" class="form-label">Arr (if any)</label>
<input type="text" class="form-control" id="arr" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
<small class="text-muted">Optional, leave empty if not using Arr</small>
</div>
</div>
{{ if .HasMultiDebrid }}
<div class="row mb-3">
<div class="col-md-6">
<label for="debrid" class="form-label">Select Debrid</label>
<select class="form-select" id="debrid" name="debrid">
{{ range $index, $debrid := .Debrids }}
<option value="{{ $debrid }}" {{ if eq $index 0 }}selected{{end}}>{{ $debrid }}</option>
{{ end }}
</select>
<small class="text-muted">Select a debrid service to use for this download</small>
</div>
</div>
{{ end }}
<div class="row mb-3">
<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 class="label">
<span class="label-text-alt">How to handle files after download completion</span>
</div>
</div>
<div class="form-control">
<label class="label" for="downloadFolder">
<span class="label-text">Download Folder</span>
</label>
<input type="text"
class="input input-bordered"
id="downloadFolder"
name="downloadFolder"
placeholder="/downloads/torrents">
<div class="label">
<span class="label-text-alt">Leave empty to use default qBittorrent folder</span>
</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
<div class="space-y-2">
<h3 class="text-lg font-semibold flex items-center">
<i class="bi bi-tags mr-2 text-warning"></i>Categorization
</h3>
<div class="form-control">
<label class="label" for="arr">
<span class="label-text">Arr Category</span>
</label>
<input type="text"
class="input input-bordered"
id="arr"
name="arr"
placeholder="sonarr, radarr, etc.">
<div class="label">
<span class="label-text-alt">Optional: Specify which Arr service should handle this</span>
</div>
</div>
{{ if .HasMultiDebrid }}
<div class="form-control">
<label class="label" for="debrid">
<span class="label-text">Debrid Service</span>
</label>
<select class="select select-bordered" id="debrid" name="debrid">
{{ range $index, $debrid := .Debrids }}
<option value="{{ $debrid }}" {{ if eq $index 0 }}selected{{end}}>
{{ $debrid }}
</option>
{{ end }}
</select>
<div class="label">
<span class="label-text-alt">Choose which debrid service to use</span>
</div>
</div>
{{ end }}
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" name="downloadUncached" id="downloadUncached">
<div>
<span class="label-text font-medium">Download Uncached Content</span>
<div class="label-text-alt">Allow downloading of content not cached by debrid service</div>
</div>
</label>
</div>
</div>
<!-- Submit Button -->
<div class="form-control">
<button type="submit" class="btn btn-primary btn-lg" id="submitDownload">
<i class="bi bi-cloud-upload mr-2"></i>Add to Download Queue
</button>
</form>
</div>
</div>
</form>
</div>
</div>
<script>
let downloadFolder = '{{ .DownloadFolder }}';
document.addEventListener('DOMContentLoaded', () => {
const loadSavedDownloadOptions = () => {
const savedCategory = localStorage.getItem('downloadCategory');
const savedAction = localStorage.getItem('downloadAction');
const savedDownloadUncached = localStorage.getItem('downloadUncached');
document.getElementById('arr').value = savedCategory || '';
document.getElementById('downloadAction').value = savedAction || 'symlink';
document.getElementById('downloadUncached').checked = savedDownloadUncached === 'true';
document.getElementById('downloadFolder').value = localStorage.getItem('downloadFolder') || downloadFolder || '';
};
const saveCurrentDownloadOptions = () => {
const arr = document.getElementById('arr').value;
const downloadAction = document.getElementById('downloadAction').value;
const downloadUncached = document.getElementById('downloadUncached').checked;
const downloadFolder = document.getElementById('downloadFolder').value;
localStorage.setItem('downloadCategory', arr);
localStorage.setItem('downloadAction', downloadAction);
localStorage.setItem('downloadUncached', downloadUncached.toString());
localStorage.setItem('downloadFolder', downloadFolder);
};
// 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('arr').value);
formData.append('downloadFolder', document.getElementById('downloadFolder').value);
formData.append('action', document.getElementById('downloadAction').value);
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
formData.append('debrid', document.getElementById('debrid') ? document.getElementById('debrid').value : '');
const response = await fetcher('/api/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('arr').addEventListener('change', saveCurrentDownloadOptions);
document.getElementById('downloadAction').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>
</div>
{{ end }}

View File

@@ -1,25 +1,34 @@
{{ 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
<div class="space-y-6">
<!-- Controls Section -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
<!-- Batch Actions -->
<div class="flex items-center gap-2">
<button class="btn btn-error btn-sm hidden" id="batchDeleteBtn">
<i class="bi bi-trash"></i>
<span class="hidden sm:inline">Delete Selected</span>
</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 class="btn btn-outline btn-sm" id="refreshBtn">
<i class="bi bi-arrow-clockwise"></i>
<span class="hidden sm:inline">Refresh</span>
</button>
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;">
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-2 w-full lg:w-auto">
<select class="select select-bordered select-sm w-full sm:w-auto min-w-32" id="stateFilter">
<option value="">All States</option>
<option value="pausedUP">PausedUP(Completed)</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">
<select class="select select-bordered select-sm w-full sm:w-auto min-w-32" 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;">
<select class="select select-bordered select-sm w-full sm:w-auto min-w-48" id="sortSelector">
<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>
@@ -31,470 +40,91 @@
</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>
<!-- Torrents Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-hover">
<thead class="bg-base-200">
<tr>
<th class="w-12">
<label class="cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm" id="selectAll">
</label>
</th>
<th class="font-semibold">
<i class="bi bi-file-text mr-2"></i>Name
</th>
<th class="font-semibold">
<i class="bi bi-hdd mr-2"></i>Size
</th>
<th class="font-semibold">
<i class="bi bi-speedometer2 mr-2"></i>Progress
</th>
<th class="font-semibold">
<i class="bi bi-download mr-2"></i>Speed
</th>
<th class="font-semibold">
<i class="bi bi-tag mr-2"></i>Category
</th>
<th class="font-semibold">
<i class="bi bi-cloud mr-2"></i>Debrid
</th>
<th class="font-semibold">
<i class="bi bi-activity mr-2"></i>State
</th>
<th class="font-semibold w-32">Actions</th>
</tr>
</thead>
<tbody id="torrentsList">
<!-- Dynamic content will be loaded here -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex flex-col sm:flex-row justify-between items-center p-6 border-t border-base-200 gap-4">
<div class="text-sm text-base-content/70">
<span id="paginationInfo">Loading torrents...</span>
</div>
<div class="join" id="paginationControls"></div>
</div>
</div>
</div>
<!-- Context menu for torrent rows -->
<div class="dropdown-menu context-menu shadow" id="torrentContextMenu">
<h6 class="dropdown-header torrent-name text-truncate"></h6>
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="copy-magnet">
<i class="bi bi-magnet me-2"></i>Copy Magnet Link
</button>
<button class="dropdown-item" data-action="copy-name">
<i class="bi bi-copy me-2"></i>Copy Name
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" data-action="delete">
<i class="bi bi-trash me-2"></i>Delete
</button>
<!-- Empty State -->
<div class="card bg-base-100 shadow-xl hidden" id="emptyState">
<div class="card-body text-center py-16">
<div class="text-6xl text-base-content/30 mb-4">
<i class="bi bi-inbox"></i>
</div>
<h3 class="text-2xl font-bold mb-2">No Torrents Found</h3>
<p class="text-base-content/70 mb-6">You haven't added any torrents yet. Start by adding your first download!</p>
<a href="{{.URLBase}}download" class="btn btn-primary">
<i class="bi bi-plus-circle mr-2"></i>Add New Download
</a>
</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'),
torrentContextMenu: document.getElementById('torrentContextMenu'),
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 || '',
selectedTorrentContextMenu: null,
sortBy: refs.sortSelector?.value || 'added_on',
itemsPerPage: 20,
currentPage: 1
};
const torrentRowTemplate = (torrent) => `
<tr data-hash="${torrent.hash}" data-magnet="${torrent.magnet || ''}" data-name="${torrent.name}">
<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 fetcher('/api/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 fetcher(`/api/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 fetcher(`/api/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">&laquo;</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">&raquo;</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);
});
document.addEventListener('click', (e) => {
if (!refs.torrentContextMenu.contains(e.target)) {
refs.torrentContextMenu.style.display = 'none';
}
});
refs.torrentsList.addEventListener('contextmenu', (e) => {
const row = e.target.closest('tr');
if (!row) return;
e.preventDefault();
state.selectedTorrentContextMenu = row.dataset.hash;
refs.torrentContextMenu.querySelector('.torrent-name').textContent = row.dataset.name;
refs.torrentContextMenu.style.display = 'block';
const { pageX, pageY } = e;
const { clientWidth, clientHeight } = document.documentElement;
const { offsetWidth, offsetHeight } = refs.torrentContextMenu;
refs.torrentContextMenu.style.maxWidth = `${clientWidth - 72}px`;
refs.torrentContextMenu.style.left = `${Math.min(pageX, clientWidth - offsetWidth - 5)}px`;
refs.torrentContextMenu.style.top = `${Math.min(pageY, clientHeight - offsetHeight - 5)}px`;
});
refs.torrentContextMenu.addEventListener('click', async (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (!action) return;
const actions = {
'copy-magnet': async (torrent) => {
try {
await navigator.clipboard.writeText(`magnet:?xt=urn:btih:${torrent.hash}`);
createToast('Magnet link copied to clipboard');
} catch (error) {
console.error('Error copying magnet link:', error);
createToast('Failed to copy magnet link', 'error');
}
},
'copy-name': async (torrent) => {
try {
await navigator.clipboard.writeText(torrent.name);
createToast('Torrent name copied to clipboard');
} catch (error) {
console.error('Error copying torrent name:', error);
createToast('Failed to copy torrent name', 'error');
}
},
'delete': async (torrent) => {
await deleteTorrent(torrent.hash, torrent.category || '', false);
}
};
const torrent = state.torrents.find(t => t.hash === state.selectedTorrentContextMenu);
if (torrent && actions[action]) {
await actions[action](torrent);
refs.torrentContextMenu.style.display = 'none';
}
});
});
</script>
<!-- Context Menu -->
<ul class="menu bg-base-100 shadow-lg rounded-box context-menu hidden fixed z-50" id="torrentContextMenu">
<li class="menu-title">
<span class="torrent-name text-sm font-bold truncate max-w-48"></span>
</li>
<li><hr class="my-1"></li>
<li><a class="menu-item text-sm" data-action="copy-magnet">
<i class="bi bi-magnet text-primary"></i>Copy Magnet Link
</a></li>
<li><a class="menu-item text-sm" data-action="copy-name">
<i class="bi bi-clipboard text-info"></i>Copy Name
</a></li>
<li><hr class="my-1"></li>
<li><a class="menu-item text-sm text-error" data-action="delete">
<i class="bi bi-trash"></i>Delete Torrent
</a></li>
</ul>
{{ end }}

View File

@@ -1,463 +1,206 @@
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<html lang="en" data-theme="dark">
<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;
}
<!-- DaisyUI and Tailwind CSS -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
display: flex;
flex-direction: column;
min-height: 100vh;
}
<!-- Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
footer {
background-color: var(--bg-color);
border-top: 1px solid var(--border-color);
}
<!-- Custom Styles -->
<link href="{{.URLBase}}assets/css/styles.css" rel="stylesheet">
footer a {
color: var(--text-color);
}
<link rel="apple-touch-icon" sizes="180x180" href="{{.URLBase}}assetsfavicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{.URLBase}}assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{.URLBase}}assets/favicon/favicon-16x16.png">
<link rel="manifest" href="{{.URLBase}}assets/favicon/site.webmanifest">
footer a:hover {
color: var(--primary-color);
}
<!-- Preload JavaScript -->
<link rel="preload" href="{{.URLBase}}assets/js/common.js" as="script">
.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;
}
.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);
}
.password-toggle-container {
position: relative;
}
.password-toggle-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6c757d;
cursor: pointer;
padding: 0;
z-index: 10;
}
.password-toggle-btn:hover {
color: #495057;
}
.form-control.has-toggle {
padding-right: 35px;
}
textarea.has-toggle {
-webkit-text-security: disc;
text-security: disc;
font-family: monospace !important;
}
textarea.has-toggle[data-password-visible="true"] {
-webkit-text-security: none;
text-security: none;
}
/* Adjust toggle button position for textareas */
.password-toggle-container textarea.has-toggle ~ .password-toggle-btn {
top: 20px;
}
</style>
<script>
// Early theme detection to prevent FOUC
(function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-bs-theme', savedTheme);
document.documentElement.setAttribute('data-theme', savedTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark');
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-bs-theme', 'light');
document.documentElement.setAttribute('data-theme', 'light');
}
})();
// Set global URL base
window.urlBase = "{{.URLBase}}";
</script>
</head>
<body>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<body class="min-h-screen bg-base-200 flex flex-col">
<!-- Toast Container -->
<div class="toast-container fixed bottom-4 right-4 z-50 space-y-2">
<!-- 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="{{.URLBase}}">
<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="{{.URLBase}}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="{{.URLBase}}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="{{.URLBase}}config">
<i class="bi bi-gear me-1"></i>Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{.URLBase}}webdav" target="_blank">
<i class="bi bi-cloud me-1"></i>WebDAV
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{.URLBase}}logs" target="_blank">
<i class="bi bi-journal me-1"></i>Logs
</a>
</li>
<!-- Navigation -->
<header class="navbar bg-base-100 shadow-lg sticky top-0 z-40 backdrop-blur-sm">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16"/>
</svg>
</div>
<ul class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
<li><a href="{{.URLBase}}" class="{{if eq .Page "index"}}active{{end}}">
<i class="bi bi-grid-3x3-gap text-primary"></i>Dashboard
</a></li>
<li><a href="{{.URLBase}}download" class="{{if eq .Page "download"}}active{{end}}">
<i class="bi bi-cloud-download text-secondary"></i>Download
</a></li>
<li><a href="{{.URLBase}}repair" class="{{if eq .Page "repair"}}active{{end}}">
<i class="bi bi-wrench-adjustable text-accent"></i>Repair
</a></li>
<li><a href="{{.URLBase}}config" class="{{if eq .Page "config"}}active{{end}}">
<i class="bi bi-gear text-info"></i>Settings
</a></li>
<li><a href="{{.URLBase}}webdav" target="_blank">
<i class="bi bi-cloud text-success"></i>WebDAV
</a></li>
<li><a href="{{.URLBase}}logs" target="_blank">
<i class="bi bi-journal-text text-warning"></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>
<a href="{{.URLBase}}debug/stats" class="me-2">
<i class="bi bi-bar-chart-line me-1"></i>Stats
</div>
<a class="btn btn-ghost text-xl font-bold text-primary group" href="{{.URLBase}}">
<!-- Logo -->
<img src="{{.URLBase}}assets/logo.svg" alt="Decypharr Logo" class="w-8 h-8 inline-block mr-2">
<span class="hidden sm:inline bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Decypharr</span>
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="{{.URLBase}}" class="{{if eq .Page "index"}}active{{end}} tooltip tooltip-bottom" data-tip="Dashboard">
<i class="bi bi-grid-3x3-gap"></i>
<span class="hidden xl:inline">Dashboard</span>
</a></li>
<li><a href="{{.URLBase}}download" class="{{if eq .Page "download"}}active{{end}} tooltip tooltip-bottom" data-tip="Add Downloads">
<i class="bi bi-cloud-download"></i>
<span class="hidden xl:inline">Download</span>
</a></li>
<li><a href="{{.URLBase}}repair" class="{{if eq .Page "repair"}}active{{end}} tooltip tooltip-bottom" data-tip="Repair Media">
<i class="bi bi-wrench-adjustable"></i>
<span class="hidden xl:inline">Repair</span>
</a></li>
<li><a href="{{.URLBase}}config" class="{{if eq .Page "config"}}active{{end}} tooltip tooltip-bottom" data-tip="Settings">
<i class="bi bi-gear"></i>
<span class="hidden xl:inline">Settings</span>
</a></li>
<li><a href="{{.URLBase}}webdav" target="_blank" class="tooltip tooltip-bottom" data-tip="WebDAV Access">
<i class="bi bi-cloud"></i>
<span class="hidden xl:inline">WebDAV</span>
</a></li>
<li><a href="{{.URLBase}}logs" target="_blank" class="tooltip tooltip-bottom" data-tip="System Logs">
<i class="bi bi-journal-text"></i>
<span class="hidden xl:inline">Logs</span>
</a></li>
</ul>
</div>
<div class="navbar-end">
<div class="flex items-center gap-3">
<!-- Theme Toggle -->
<div class="tooltip tooltip-left" data-tip="Toggle Theme">
<label class="swap swap-rotate btn btn-ghost btn-circle hover:bg-base-300 transition-colors">
<input type="checkbox" id="themeToggle" class="theme-controller" />
<!-- Sun icon for light mode -->
<i class="swap-off bi bi-sun text-lg text-warning"></i>
<!-- Moon icon for dark mode -->
<i class="swap-on bi bi-moon-stars text-lg text-info"></i>
</label>
</div>
<!-- Stats Link -->
<div class="tooltip tooltip-left" data-tip="System Statistics">
<a href="{{.URLBase}}debug/stats" class="btn btn-ghost btn-sm hover:bg-base-300 transition-colors">
<i class="bi bi-graph-up text-lg"></i>
<span class="hidden md:inline ml-1">Stats</span>
</a>
<span class="badge bg-primary" id="version-badge">Loading...</span>
</div>
<!-- Version Badge -->
<div class="tooltip tooltip-left" data-tip="Current Version">
<div class="badge badge-primary font-mono text-xs hover:badge-primary-focus transition-colors cursor-pointer" id="version-badge">
Loading...
</div>
</div>
</div>
</div>
</nav>
</header>
{{ 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 "register" }}
{{ template "register" . }}
{{ else }}
{{ end }}
<footer class="mt-auto py-2 text-center border-top">
<div class="container">
<small class="text-muted">
<a href="https://github.com/sirrobot01/decypharr" target="_blank" class="text-decoration-none me-3">
<i class="bi bi-github me-1"></i>GitHub
</a>
<a href="https://sirrobot01.github.io/decypharr" target="_blank" class="text-decoration-none">
<i class="bi bi-book me-1"></i>Documentation
</a>
</small>
<!-- Main Content -->
<main class="flex-1 container mx-auto px-4 py-6">
{{ 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 "register" }}
{{ template "register" . }}
{{ else }}
<div class="hero min-h-96">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold text-error">404</h1>
<p class="py-6">Page not found. The page you're looking for doesn't exist.</p>
<a href="{{.URLBase}}" class="btn btn-primary">Go Home</a>
</div>
</div>
</div>
{{ end }}
</main>
<!-- Footer -->
<footer class="footer footer-center p-6 bg-base-300 text-base-content border-t border-base-200">
<aside class="grid-flow-col gap-4">
<a href="https://github.com/sirrobot01/decypharr" target="_blank"
class="link link-hover flex items-center gap-2 hover:text-primary transition-colors">
<i class="bi bi-github text-lg"></i>
<span>GitHub</span>
</a>
<a href="https://sirrobot01.github.io/decypharr" target="_blank"
class="link link-hover flex items-center gap-2 hover:text-primary transition-colors">
<i class="bi bi-book text-lg"></i>
<span>Documentation</span>
</a>
</aside>
</footer>
<!-- Scripts -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="{{.URLBase}}assets/js/common.js"></script>
<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>
window.urlBase = "{{.URLBase}}";
function joinURL(base, path) {
if (!base.endsWith('/')) {
base += '/';
}
if (path.startsWith('/')) {
path = path.substring(1);
}
return base + path;
}
function fetcher(endpoint, options = {}) {
// Use the global urlBase or default to empty string
let baseUrl = window.urlBase || '';
let url = joinURL(baseUrl, endpoint);
// Return the regular fetcher with the complete URL
return fetch(url, options);
}
/**
* 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();
});
};
function createPasswordField(name, id, placeholder = "", required = false) {
return `
<div class="password-toggle-container">
<input type="password"
class="form-control has-toggle"
name="${name}"
id="${id}"
placeholder="${placeholder}"
${required ? 'required' : ''}>
<button type="button"
class="password-toggle-btn"
onclick="togglePassword('${id}');">
<i class="bi bi-eye" id="${id}_icon"></i>
</button>
</div>
`;
}
function togglePassword(fieldId) {
const field = document.getElementById(fieldId);
const icon = document.getElementById(fieldId + '_icon');
if (field.type === 'password') {
field.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
field.type = 'password';
icon.className = 'bi bi-eye';
}
}
// Add this function to handle textarea password toggling
function togglePasswordTextarea(fieldId) {
const field = document.getElementById(fieldId);
const icon = document.getElementById(fieldId + '_icon');
if (field.style.webkitTextSecurity === 'disc' || field.style.webkitTextSecurity === '') {
// Show text
field.style.webkitTextSecurity = 'none';
field.style.textSecurity = 'none'; // For other browsers
field.setAttribute('data-password-visible', 'true');
icon.className = 'bi bi-eye-slash';
} else {
// Hide text
field.style.webkitTextSecurity = 'disc';
field.style.textSecurity = 'disc'; // For other browsers
field.setAttribute('data-password-visible', 'false');
icon.className = 'bi bi-eye';
}
}
// 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() {
fetcher('/version')
.then(response => response.json())
.then(data => {
const versionBadge = document.getElementById('version-badge');
// Add url to version badge
versionBadge.innerHTML = `<a href="https://github.com/sirrobot01/decypharr/releases/tag/${data.version}" target="_blank" class="text-white">${data.channel}-${data.version}</a>`;
if (data.channel === 'beta') {
versionBadge.classList.add('beta');
} else if (data.channel === 'nightly') {
versionBadge.classList.add('nightly');
}
})
.catch(error => {
console.error('Error fetching version:', error);
document.getElementById('version-badge').textContent = 'Unknown';
});
});
</script>
<!-- Page-specific scripts -->
{{ if eq .Page "index" }}
<script src="{{.URLBase}}assets/js/dashboard.js"></script>
{{ else if eq .Page "download" }}
<script src="{{.URLBase}}assets/js/download.js"></script>
{{ else if eq .Page "repair" }}
<script src="{{.URLBase}}assets/js/repair.js"></script>
{{ else if eq .Page "config" }}
<script src="{{.URLBase}}assets/js/config.js"></script>
{{ end }}
</body>
</html>
{{ end }}

View File

@@ -1,27 +1,25 @@
{{ 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 class="flex min-h-screen items-center justify-center bg-base-200">
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center mb-6">Login</h2>
<form id="loginForm" class="space-y-4">
<div class="form-control">
<label class="label" for="username">
<span class="label-text">Username</span>
</label>
<input type="text" class="input input-bordered w-full" id="username" name="username" required>
</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 class="form-control">
<label class="label" for="password">
<span class="label-text">Password</span>
</label>
<input type="password" class="input input-bordered w-full" id="password" name="password" required>
</div>
</div>
<div class="form-control mt-6">
<button type="submit" class="btn btn-primary w-full">Login</button>
</div>
</form>
</div>
</div>
</div>
@@ -29,6 +27,7 @@
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
let loginBtn = document.querySelector('#loginForm button[type="submit"]');
const formData = {
username: document.getElementById('username').value,
@@ -45,13 +44,21 @@
});
if (response.ok) {
window.location.href = '/';
window.decypharrUtils.createToast('Login successful! Redirecting...', 'success');
// Redirect after a short delay
setTimeout(() => {
window.location.href = window.urlBase || '/';
}, 1000);
} else {
createToast('Invalid credentials', 'error');
const errorText = await response.text();
throw new Error(errorText || 'Invalid credentials');
}
} catch (error) {
console.error('Login error:', error);
createToast('Login failed', 'error');
window.decypharrUtils.createToast(error.message || 'Login failed. Please try again.', 'error');
} finally {
window.decypharrUtils.setButtonLoading(loginBtn, false);
}
});
</script>

View File

@@ -1,32 +1,32 @@
{{ define "register" }}
<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 Auth Setup</h4>
<div class="flex min-h-screen items-center justify-center bg-base-200">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center mb-6">First Time Auth Setup</h2>
<form id="authForm" class="space-y-4">
<div class="form-control">
<label class="label" for="username">
<span class="label-text">Username</span>
</label>
<input type="text" class="input input-bordered w-full" id="username" name="username" required>
</div>
<div class="card-body">
<form id="authForm">
<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="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 gap-2">
<button type="submit" class="btn btn-primary mb-2">Save</button>
<button type="button" id="skipAuthBtn" class="btn btn-secondary">Skip</button>
</div>
</form>
<div class="form-control">
<label class="label" for="password">
<span class="label-text">Password</span>
</label>
<input type="password" class="input input-bordered w-full" id="password" name="password" required>
</div>
</div>
<div class="form-control">
<label class="label" for="confirmPassword">
<span class="label-text">Confirm Password</span>
</label>
<input type="password" class="input input-bordered w-full" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="form-control mt-6 space-y-2">
<button type="submit" class="btn btn-primary w-full">Save</button>
<button type="button" id="skipAuthBtn" class="btn btn-secondary w-full">Skip</button>
</div>
</form>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff