Files
domnitor/app/Views/domains/index.twig
Hosteroid 8559e903b9 Add DNS monitoring and refresh functionality
Introduce DNS monitoring: add DnsService (comprehensive DNS lookup, crt.sh discovery, Cloudflare detection, IP enrichment) and a new DnsRecord model to persist snapshots, manage diffs, and provide queries/stats. Update DomainController to support a dns_monitoring_enabled flag, refactor WHOIS/DNS refresh logic into performWhoisRefresh/performDnsRefresh, and add endpoints for refreshWhois, refreshDns and refreshAll; send notifications when DNS monitoring is toggled. Add UI templates/tabs for DNS, billing, notifications, overview, SSL and WHOIS and wire DNS data into the domain view; expose cached IP details. Add cron/check_dns.php and migration 027_add_dns_monitoring.sql (and include it in installer migration lists). Other tweaks: safer EmailHelper subject handling, TldRegistry search improvements, domain sorting using an effective status (expiring_soon), Discord channel null-safe fields, settings UI additions (domain_view_template and cron staleness warnings), and route/migration updates. This enables scheduled and manual DNS scans with persistent records and notifications.
2026-03-08 14:32:05 +02:00

982 lines
52 KiB
Twig

{% extends 'layout/base.twig' %}
{% set title = 'Domains' %}
{% set pageTitle = 'Domain Management' %}
{% set pageDescription = 'Monitor and manage your domain portfolio' %}
{% set pageIcon = 'fas fa-globe' %}
{% block content %}
{# Action Buttons #}
<div class="mb-4 flex gap-2 justify-end">
{# Export Dropdown #}
<div class="relative" id="domainExportDropdownWrapper">
<button onclick="document.getElementById('domainExportMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
<i class="fas fa-download mr-2"></i>
Export
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="domainExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 z-30 overflow-hidden">
<a href="/domains/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
Export as CSV
</a>
<a href="/domains/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors border-t border-gray-100 dark:border-slate-700">
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
Export as JSON
</a>
</div>
</div>
<a href="/domains/bulk-add" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-layer-group mr-2"></i>
Bulk Add
</a>
<a href="/domains/create" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-plus mr-2"></i>
Add Domain
</a>
</div>
{# Filters & Search #}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 mb-4">
<form method="GET" action="/domains" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-5 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Search</label>
<div class="relative">
<input type="text" name="search" id="domainSearch" value="{{ filters.search }}" placeholder="Search domains..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Status</label>
<select name="status" id="statusFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<option value="">All Statuses</option>
<option value="active" {{ filters.status == 'active' ? 'selected' : '' }}>Active</option>
<option value="expiring_soon" {{ filters.status == 'expiring_soon' ? 'selected' : '' }}>Expiring Soon</option>
<option value="expired" {{ filters.status == 'expired' ? 'selected' : '' }}>Expired</option>
<option value="available" {{ filters.status == 'available' ? 'selected' : '' }}>Available</option>
<option value="redemption_period" {{ filters.status == 'redemption_period' ? 'selected' : '' }}>Redemption Period</option>
<option value="pending_delete" {{ filters.status == 'pending_delete' ? 'selected' : '' }}>Pending Delete</option>
<option value="error" {{ filters.status == 'error' ? 'selected' : '' }}>Error</option>
<option value="inactive" {{ filters.status == 'inactive' ? 'selected' : '' }}>Inactive</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Tags</label>
<select name="tag" id="tagFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<option value="">All Tags</option>
{% set tagIcons = {
'production': '🟢',
'staging': '🟡',
'development': '🔵',
'client': '🟣',
'personal': '🟠',
'archived': '⚪'
} %}
{% for tagOption in allTags %}
{% set icon = tagIcons[tagOption]|default('🏷️') %}
<option value="{{ tagOption }}" {{ filters.tag|default('') == tagOption ? 'selected' : '' }}>
{{ icon }} {{ tagOption|capitalize }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Group</label>
<select name="group" id="groupFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<option value="">All Groups</option>
{% for group in groups %}
<option value="{{ group.id }}" {{ filters.group == group.id ? 'selected' : '' }}>{{ group.name }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end space-x-2">
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i>
Apply
</button>
<a href="/domains" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm font-medium">
<i class="fas fa-times"></i>
Clear
</a>
</div>
</div>
<input type="hidden" name="sort" value="{{ filters.sort }}">
<input type="hidden" name="order" value="{{ filters.order }}">
</form>
</div>
{# Pagination Info & Per Page Selector #}
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600 dark:text-slate-400">
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_from }}</span> to
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to }}</span> of
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total }}</span> domain(s)
</div>
<form method="GET" action="/domains" class="flex items-center gap-2">
<input type="hidden" name="search" value="{{ filters.search }}">
<input type="hidden" name="status" value="{{ filters.status }}">
<input type="hidden" name="group" value="{{ filters.group }}">
<input type="hidden" name="sort" value="{{ filters.sort }}">
<input type="hidden" name="order" value="{{ filters.order }}">
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<option value="10" {{ pagination.per_page == 10 ? 'selected' : '' }}>10</option>
<option value="25" {{ pagination.per_page == 25 ? 'selected' : '' }}>25</option>
<option value="50" {{ pagination.per_page == 50 ? 'selected' : '' }}>50</option>
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
</select>
</form>
</div>
{# Domains List #}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
{# Bulk Actions Bar #}
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-500/30 flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-gray-700 dark:text-slate-300"></span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
<i class="fas fa-sync-alt mr-1"></i> Refresh Selected
</button>
{% if auth.isAdmin %}
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
</button>
{% endif %}
<div class="relative inline-block">
<button type="button" onclick="toggleAssignTagsDropdown()" class="inline-flex items-center px-4 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
<i class="fas fa-tags mr-1"></i> Manage Tags
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 z-10">
<div class="p-3">
<div class="flex items-center justify-between mb-3">
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300">Tag Management</label>
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-cog mr-1"></i>
Manage Tags
</a>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-2">Add Tags to Selected Domains</label>
<div class="flex flex-wrap gap-1.5 mb-3">
{% for tag in availableTags %}
<button type="button" onclick="bulkAssignExistingTag({{ tag.id }}, '{{ tag.name|e('js') }}')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
{{ tag.name }}
</button>
{% endfor %}
</div>
<div class="border-t border-gray-200 dark:border-slate-700 pt-2">
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded hover:bg-blue-200 dark:hover:bg-blue-500/30 font-medium">
<i class="fas fa-plus mr-1"></i>
Add Custom Tag
</button>
</div>
</div>
<div class="border-t border-gray-200 dark:border-slate-700 pt-3">
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-2">Remove Tags from Selected Domains</label>
<div class="space-y-2">
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 text-xs rounded hover:bg-gray-200 dark:hover:bg-slate-600 font-medium">
<i class="fas fa-times mr-1"></i>
Remove All Tags
</button>
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded hover:bg-red-200 dark:hover:bg-red-500/30 font-medium">
<i class="fas fa-minus mr-1"></i>
Remove Specific Tag
</button>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-slate-700 p-2">
<button type="button" onclick="toggleAssignTagsDropdown()" class="w-full px-3 py-1.5 bg-gray-200 dark:bg-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded hover:bg-gray-300 dark:hover:bg-slate-500">
Close
</button>
</div>
</div>
</div>
<div class="relative inline-block">
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium">
<i class="fas fa-bell mr-1"></i> Assign Group
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 z-10">
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
{{ csrf_field() }}
<div class="p-3">
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<option value="">-- No Group --</option>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</div>
<div class="border-t border-gray-200 dark:border-slate-700 p-2 flex gap-2">
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
Assign
</button>
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 dark:bg-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded hover:bg-gray-300 dark:hover:bg-slate-500">
Cancel
</button>
</div>
</form>
</div>
</div>
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
<i class="fas fa-trash mr-1"></i> Delete Selected
</button>
</div>
</div>
</div>
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-white hover:bg-blue-100 dark:hover:bg-blue-500/20 px-3 py-1.5 rounded-lg transition-colors">
<i class="fas fa-times mr-1.5"></i> Clear Selection
</button>
</div>
{% if domains is not empty %}
{# Table View (Desktop) #}
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-6 py-3 text-left">
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary">
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="{{ sort_url('domain_name', filters.sort, filters.order, filters) }}" class="hover:text-primary flex items-center">
Domain {{ sort_icon('domain_name', filters.sort, filters.order)|raw }}
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="{{ sort_url('registrar', filters.sort, filters.order, filters) }}" class="hover:text-primary flex items-center">
Registrar {{ sort_icon('registrar', filters.sort, filters.order)|raw }}
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="{{ sort_url('expiration_date', filters.sort, filters.order, filters) }}" class="hover:text-primary flex items-center">
Expiration {{ sort_icon('expiration_date', filters.sort, filters.order)|raw }}
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="{{ sort_url('status', filters.sort, filters.order, filters) }}" class="hover:text-primary flex items-center">
Status {{ sort_icon('status', filters.sort, filters.order)|raw }}
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="{{ sort_url('group_name', filters.sort, filters.order, filters) }}" class="hover:text-primary flex items-center">
Group {{ sort_icon('group_name', filters.sort, filters.order)|raw }}
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="{{ sort_url('last_checked', filters.sort, filters.order, filters) }}" class="hover:text-primary flex items-center">
Last Checked {{ sort_icon('last_checked', filters.sort, filters.order)|raw }}
</a>
</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
{% for domain in domains %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150 domain-row">
<td class="px-6 py-4">
<input type="checkbox" class="domain-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ domain.id }}" onchange="updateBulkActions()">
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 dark:bg-opacity-20 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-primary"></i>
</div>
<div class="ml-4">
<a href="/domains/{{ domain.id }}" class="text-sm font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
<div class="flex items-center gap-1.5 mt-1">
{% set domainTags = domain.tags ? domain.tags|split(',') : [] %}
{% set tagColors = domain.tag_colors ? domain.tag_colors|split('|') : [] %}
{% for tag in domainTags %}
{% set colorClass = tagColors[loop.index0]|default('bg-gray-100 text-gray-700 border-gray-200 dark:bg-slate-700 dark:text-slate-300 dark:border-slate-600') %}
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border {{ colorClass }}">
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
{{ tag|trim|capitalize }}
</span>
{% endfor %}
{% if domain.nameservers and domainTags is empty %}
<span class="text-xs text-gray-500 dark:text-slate-400">NS: {{ domain.nameservers|split(',')[0] }}</span>
{% endif %}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if domain.registrar %}
<div class="flex items-center">
<i class="fas fa-building text-gray-400 dark:text-slate-500 mr-2"></i>
<span class="text-sm text-gray-900 dark:text-white">{{ domain.registrar }}</span>
</div>
{% else %}
<span class="text-sm text-gray-400 dark:text-slate-500">Unknown</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if domain.expiration_date %}
<div class="text-sm">
<div class="font-medium text-gray-900 dark:text-white flex items-center">
{{ domain.expiration_date|date('M d, Y') }}
{% if domain.isManualExpiration %}
<span class="ml-1 inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/10 text-amber-800 dark:text-amber-400" title="Manual expiration date">
<i class="fas fa-edit" style="font-size: 8px;"></i>
</span>
{% endif %}
</div>
<div class="text-xs {{ domain.expiryClass }}">
{{ domain.daysLeft }} days
</div>
</div>
{% else %}
<span class="text-sm text-gray-400 dark:text-slate-500">Not set</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border {{ domain.statusClass }}">
<i class="fas {{ domain.statusIcon }} mr-1"></i>
{{ domain.statusText }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{% if domain.group_name %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
<i class="fas fa-bell mr-1"></i>
{{ domain.group_name }}
</span>
{% else %}
<span class="text-gray-400 dark:text-slate-500">No Group</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-slate-400">
{% if domain.last_checked %}
<div class="flex items-center">
<i class="far fa-clock mr-2"></i>
{{ domain.last_checked|date('M d, H:i') }}
</div>
{% else %}
<span class="text-gray-400 dark:text-slate-500">Never</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
<i class="fas fa-eye"></i>
</a>
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
{{ csrf_field() }}
<button type="submit" class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
<i class="fas fa-sync-alt"></i>
</button>
</form>
<a href="/domains/{{ domain.id }}/edit?from=/domains" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form method="POST" action="/domains/{{ domain.id }}/delete" class="inline" onsubmit="return confirm('Delete this domain?')">
{{ csrf_field() }}
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Card View (Mobile) #}
<div class="lg:hidden divide-y divide-gray-200 dark:divide-slate-700">
{% for domain in domains %}
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
<div class="flex items-center mb-3">
<input type="checkbox" class="domain-checkbox-mobile rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary mr-3" value="{{ domain.id }}" onchange="updateBulkActions()">
<a href="/domains/{{ domain.id }}" class="text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-12 px-6">
<div class="mb-4">
<i class="fas fa-globe text-gray-300 dark:text-slate-600 text-6xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Domains Yet</h3>
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Start monitoring your domains by adding your first one</p>
<a href="/domains/create" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
<span>Add Your First Domain</span>
</a>
</div>
{% endif %}
</div>
{# Pagination Controls #}
{% if pagination.total_pages > 1 %}
{% set currentPage = pagination.current_page %}
{% set totalPages = pagination.total_pages %}
{% set range = 2 %}
{% set start = max(1, currentPage - range) %}
{% set end = min(totalPages, currentPage + range) %}
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
{# Page Info #}
<div class="text-sm text-gray-600 dark:text-slate-400">
Page <span class="font-semibold text-gray-900 dark:text-white">{{ currentPage }}</span> of
<span class="font-semibold text-gray-900 dark:text-white">{{ totalPages }}</span>
</div>
{# Pagination Buttons #}
<div class="flex items-center gap-1">
{# First Page #}
{% if currentPage > 1 %}
<a href="{{ pagination_url(1, filters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
<i class="fas fa-angle-double-left"></i>
</a>
{% endif %}
{# Previous Page #}
{% if currentPage > 1 %}
<a href="{{ pagination_url(currentPage - 1, filters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
<i class="fas fa-angle-left"></i> Previous
</a>
{% endif %}
{# First page + ellipsis if needed #}
{% if start > 1 %}
<a href="{{ pagination_url(1, filters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">1</a>
{% if start > 2 %}
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
{% endif %}
{% endif %}
{# Page Numbers #}
{% for i in start..end %}
{% if i == currentPage %}
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
{% else %}
<a href="{{ pagination_url(i, filters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ i }}</a>
{% endif %}
{% endfor %}
{# Last page + ellipsis if needed #}
{% if end < totalPages %}
{% if end < totalPages - 1 %}
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
{% endif %}
<a href="{{ pagination_url(totalPages, filters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ totalPages }}</a>
{% endif %}
{# Next Page #}
{% if currentPage < totalPages %}
<a href="{{ pagination_url(currentPage + 1, filters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
Next <i class="fas fa-angle-right"></i>
</a>
{% endif %}
{# Last Page #}
{% if currentPage < totalPages %}
<a href="{{ pagination_url(totalPages, filters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
<i class="fas fa-angle-double-right"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.domain-checkbox, .domain-checkbox-mobile');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBulkActions();
}
function updateBulkActions() {
const checkboxes = document.querySelectorAll('.domain-checkbox:checked, .domain-checkbox-mobile:checked');
const bulkActions = document.getElementById('bulk-actions');
const selectedCount = document.getElementById('selected-count');
const selectAllCheckbox = document.getElementById('select-all');
const uniqueIds = new Set(Array.from(checkboxes).map(cb => cb.value));
const count = uniqueIds.size;
if (count > 0) {
bulkActions.classList.remove('hidden');
selectedCount.textContent = count + ' domain(s) selected';
} else {
bulkActions.classList.add('hidden');
}
const allCheckboxes = document.querySelectorAll('.domain-checkbox');
const checkedDesktopBoxes = document.querySelectorAll('.domain-checkbox:checked');
if (selectAllCheckbox) {
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkedDesktopBoxes.length === allCheckboxes.length;
selectAllCheckbox.indeterminate = checkedDesktopBoxes.length > 0 && checkedDesktopBoxes.length < allCheckboxes.length;
}
}
function clearSelection() {
const checkboxes = document.querySelectorAll('.domain-checkbox, .domain-checkbox-mobile');
checkboxes.forEach(cb => {
cb.checked = false;
});
document.getElementById('select-all').checked = false;
updateBulkActions();
}
function getSelectedIds() {
const checkboxes = document.querySelectorAll('.domain-checkbox:checked, .domain-checkbox-mobile:checked');
const ids = Array.from(checkboxes).map(cb => cb.value);
return [...new Set(ids)];
}
function bulkRefresh() {
const ids = getSelectedIds();
if (ids.length === 0) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-refresh';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput);
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkDelete() {
const ids = getSelectedIds();
if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} domain(s)? This action cannot be undone.`)) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-delete';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput);
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function toggleAssignTagsDropdown() {
const dropdown = document.getElementById('assign-tags-dropdown');
dropdown.classList.toggle('hidden');
}
function toggleAssignGroupDropdown() {
const dropdown = document.getElementById('assign-group-dropdown');
dropdown.classList.toggle('hidden');
}
function bulkAddTag(tagName) {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-add-tags';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput);
const tagInput = document.createElement('input');
tagInput.type = 'hidden';
tagInput.name = 'tag';
tagInput.value = tagName;
form.appendChild(tagInput);
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkRemoveAllTags() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-remove-tags';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput);
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkTransfer() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
const users = {{ users|default([])|json_encode|raw }};
if (users.length === 0) {
alert('No users available for transfer');
return;
}
let userOptions = users.map(user =>
`<option value="${user.id}">${user.username} (${user.full_name || user.email})</option>`
).join('');
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50';
modal.innerHTML = `
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-96 shadow-lg rounded-md bg-white dark:bg-slate-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Transfer ${ids.length} Domain(s)</h3>
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Select the user to transfer the selected domains to:</p>
<form method="POST" action="/domains/bulk-transfer">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
${ids.map(id => `<input type="hidden" name="domain_ids[]" value="${id}">`).join('')}
<div class="mb-4">
<label for="target_user_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User:</label>
<select name="target_user_id" id="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<option value="">Select a user...</option>
${userOptions}
</select>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 dark:bg-slate-600 text-gray-700 dark:text-slate-300 rounded-md hover:bg-gray-400 dark:hover:bg-slate-500">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark">
Transfer Domains
</button>
</div>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
}
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
const ids = getSelectedIds();
const container = this;
container.querySelectorAll('input[name="domain_ids[]"]').forEach(el => el.remove());
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
container.appendChild(input);
});
});
document.addEventListener('click', function(event) {
const groupDropdown = document.getElementById('assign-group-dropdown');
const tagsDropdown = document.getElementById('assign-tags-dropdown');
const groupButton = event.target.closest('button[onclick="toggleAssignGroupDropdown()"]');
const tagsButton = event.target.closest('button[onclick="toggleAssignTagsDropdown()"]');
if (!groupButton && !groupDropdown.contains(event.target)) {
groupDropdown?.classList.add('hidden');
}
if (!tagsButton && !tagsDropdown.contains(event.target)) {
tagsDropdown?.classList.add('hidden');
}
});
function bulkAssignExistingTag(tagId, tagName) {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-assign-existing-tag';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput);
const tagInput = document.createElement('input');
tagInput.type = 'hidden';
tagInput.name = 'tag_id';
tagInput.value = tagId;
form.appendChild(tagInput);
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function openTagSelector() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 z-50';
modal.innerHTML = `
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full">
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Add Custom Tag</h3>
</div>
<div class="px-6 py-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Tag Name</label>
<input type="text" id="custom-tag-name" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white" placeholder="Enter tag name">
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Use only letters, numbers, and hyphens</p>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-slate-900 flex justify-end space-x-3 rounded-b-lg">
<button type="button" onclick="closeTagSelector()" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-600">
Cancel
</button>
<button type="button" onclick="submitCustomTag()" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
Add Tag
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('custom-tag-name').focus();
}
function closeTagSelector() {
const modal = document.querySelector('.fixed.inset-0.bg-gray-600');
if (modal) {
modal.remove();
}
}
function submitCustomTag() {
const tagName = document.getElementById('custom-tag-name').value.trim();
if (!tagName) {
alert('Please enter a tag name');
return;
}
if (!/^[a-z0-9-]+$/.test(tagName)) {
alert('Invalid tag name format (use only letters, numbers, and hyphens)');
return;
}
bulkAddTag(tagName);
closeTagSelector();
}
function openTagRemovalSelector() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 z-50';
modal.innerHTML = `
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full">
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Remove Specific Tag</h3>
</div>
<div class="px-6 py-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Select Tag to Remove</label>
<select id="tag-to-remove" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
<option value="">Loading tags...</option>
</select>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-slate-900 flex justify-end space-x-3 rounded-b-lg">
<button type="button" onclick="closeTagRemovalSelector()" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-600">
Cancel
</button>
<button type="button" onclick="submitTagRemoval()" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
Remove Tag
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const select = document.getElementById('tag-to-remove');
select.innerHTML = '<option value="">Loading tags...</option>';
fetch('/domains/get-tags-for-domains', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain_ids: ids,
csrf_token: '{{ csrf_token() }}'
})
})
.then(response => response.json())
.then(data => {
select.innerHTML = '<option value="">Select a tag to remove</option>';
if (data.tags && data.tags.length > 0) {
data.tags.forEach(tag => {
const option = document.createElement('option');
option.value = tag.id;
option.textContent = tag.name;
select.appendChild(option);
});
} else {
select.innerHTML = '<option value="">No tags found on selected domains</option>';
}
})
.catch(error => {
console.error('Error loading tags:', error);
select.innerHTML = '<option value="">Error loading tags</option>';
});
}
function closeTagRemovalSelector() {
const modal = document.querySelector('.fixed.inset-0.bg-gray-600');
if (modal) {
modal.remove();
}
}
function submitTagRemoval() {
const tagId = document.getElementById('tag-to-remove').value;
if (!tagId) {
alert('Please select a tag to remove');
return;
}
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-remove-specific-tag';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput);
const tagInput = document.createElement('input');
tagInput.type = 'hidden';
tagInput.name = 'tag_id';
tagInput.value = tagId;
form.appendChild(tagInput);
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('domainExportDropdownWrapper');
if (wrapper && !wrapper.contains(e.target)) {
document.getElementById('domainExportMenu').classList.add('hidden');
}
});
</script>
{% endblock %}