Initial Release v1.0.0: Full feature set with Docker automation, Nginx/Alpine stack

This commit is contained in:
dyzulk
2026-01-16 11:21:32 +07:00
commit 45623973a8
139 changed files with 24302 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
<?php
$title = "Hotspot Cookies";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="cookies.title">Hotspot Cookies</h1>
<p class="text-accents-5"><span data-i18n="cookies.subtitle">Active authentication cookies for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button>
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a>
</div>
</div>
<?php if (isset($error) && $error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search user, mac..." data-i18n="common.table.search_placeholder">
</div>
</div>
<div class="table-container">
<table class="table-glass" id="cookies-table">
<thead>
<tr>
<th data-sort="user" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="cookies.user">User</th>
<th data-i18n="cookies.mac">MAC Address</th>
<th data-i18n="cookies.ip">IP Address</th>
<th data-sort="expires" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="cookies.expires">Expires In</th>
<th class="relative text-right" data-i18n="common.actions">Action</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($cookies) && is_array($cookies)): ?>
<?php foreach ($cookies as $cookie): ?>
<tr class="table-row-item"
data-user="<?= strtolower($cookie['user'] ?? '') ?>"
data-mac="<?= strtolower($cookie['mac-address'] ?? '') ?>"
data-expires="<?= htmlspecialchars($cookie['expires-in'] ?? '') ?>">
<td>
<span class="text-sm font-medium text-foreground"><?= htmlspecialchars($cookie['user'] ?? '-') ?></span>
</td>
<td>
<span class="font-mono text-sm text-accents-5 uppercase"><?= htmlspecialchars($cookie['mac-address'] ?? '-') ?></span>
</td>
<td>
<span class="font-mono text-sm text-foreground"><?= htmlspecialchars($cookie['ip'] ?? '-') ?></span>
</td>
<td>
<span class="text-sm text-accents-5"><?= htmlspecialchars($cookie['expires-in'] ?? '-') ?></span>
</td>
<td class="text-right text-sm font-medium">
<div class="flex justify-end table-actions-reveal">
<form action="/<?= htmlspecialchars($session) ?>/hotspot/cookies/remove" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('cookies.remove_cookie') : 'Remove Cookie?', window.i18n ? window.i18n.t('cookies.remove_confirm', {user: '<?= htmlspecialchars($cookie['user'] ?? '') ?>'}) : 'Are you sure you want to remove the cookie for <?= htmlspecialchars($cookie['user'] ?? '') ?>?', window.i18n ? window.i18n.t('common.delete') : 'Remove', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $cookie['.id'] ?>">
<button type="submit" class="p-1.5 text-red-500 hover:bg-red-500/10 rounded transition-colors" title="Remove">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5" data-i18n="common.table.showing" data-i18n-params='{"start": "0", "end": "0", "total": "0"}'>
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> cookies
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '' };
this.init();
}
init() {
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
// Placeholder translation handled via data-i18n-placeholder
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const user = row.dataset.user || '';
const mac = row.dataset.mac || '';
if (this.filters.search) {
if (!user.includes(this.filters.search) && !mac.includes(this.filters.search)) return false;
}
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
} else {
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
}
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>

View File

@@ -0,0 +1,260 @@
<?php require_once ROOT . '/app/Views/layouts/header_main.php'; ?>
<?php require_once ROOT . '/app/Views/layouts/sidebar_session.php'; ?>
<!-- Content Inside max-w-7xl (Opened by sidebar.php) -->
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold tracking-tight text-foreground" data-i18n="hotspot_generate.title">Generate Vouchers</h1>
<p class="text-sm text-accents-5" data-i18n="hotspot_generate.form.subtitle" data-i18n-params='{"name": "<?= htmlspecialchars($session) ?>"}'>Create multiple hotspot vouchers in batch for: <span class="font-medium text-foreground"><?= htmlspecialchars($session) ?></span></p>
</div>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="btn btn-secondary" data-i18n="common.back">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
Back to Users
</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Form Column -->
<div class="lg:col-span-2">
<div class="card p-0 overflow-hidden border-accents-2 shadow-sm">
<div class="p-6 border-b border-accents-2 bg-accents-1/30">
<h3 class="text-lg font-semibold flex items-center gap-2">
<div class="p-2 bg-primary/10 rounded-lg text-primary">
<i data-lucide="layers" class="w-5 h-5"></i>
</div>
<span data-i18n="hotspot_generate.form.batch_settings">Batch Generation Settings</span>
</h3>
</div>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/generate/process" method="POST" class="p-8 space-y-8">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Core Settings (Full Width on Mobile, Half on MD) -->
<div class="space-y-6">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2 mb-4" data-i18n="hotspot_generate.form.core_config">Core Config</h4>
<!-- Quantity -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.qty">Quantity</label>
<div class="input-group">
<input type="number" name="qty" class="form-input w-full text-lg font-bold text-primary border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20 pr-16" value="1" min="1" required>
<div class="input-suffix text-xs font-bold text-accents-4 uppercase" data-i18n="hotspot_users.title">Users</div>
</div>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.qty_help">Count of vouchers to generate.</p>
</div>
<!-- Server -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.server">Server</label>
<select name="server" class="custom-select w-full" data-search="true">
<option value="all">all</option>
<?php if(isset($servers) && is_array($servers)): ?>
<?php foreach($servers as $srv): ?>
<option value="<?= htmlspecialchars($srv['name']) ?>">
<?= htmlspecialchars($srv['name']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.server_help">Target Hotspot Instance.</p>
</div>
<!-- User Mode -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.user_mode">User Mode</label>
<select name="userModel" class="custom-select w-full">
<option value="up">Username & Password</option>
<option value="vc">Username = Password</option>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.user_mode_help">Login credential format.</p>
</div>
<!-- Comment -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.comment">Comment</label>
<div class="input-group">
<span class="input-icon">
<i data-lucide="message-square" class="w-4 h-4"></i>
</span>
<input type="text" name="comment" class="form-input w-full" data-i18n-placeholder="hotspot_generate.form.comment_help" placeholder="Batch note...">
</div>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.comment_help">Note for this batch.</p>
</div>
</div>
<!-- User Format -->
<div class="space-y-6">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2 mb-4" data-i18n="hotspot_generate.form.user_format">User Format</h4>
<!-- Name Length -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.user_length">Name Length</label>
<select name="userLength" class="custom-select w-full">
<?php for($i=3; $i<=8; $i++): ?>
<option value="<?= $i ?>" <?= $i==4 ? 'selected' : '' ?>><?= $i ?></option>
<?php endfor; ?>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.name_length_help">Length of username/password.</p>
</div>
<!-- Prefix -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.prefix">Prefix</label>
<div class="input-group">
<span class="input-icon">
<i data-lucide="type" class="w-4 h-4"></i>
</span>
<input type="text" name="prefix" class="form-input w-full" data-i18n-placeholder="hotspot_generate.form.prefix_placeholder" placeholder="e.g. VIP-">
</div>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.prefix_help">Prefix for generated usernames.</p>
</div>
<!-- Character Set -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.characters">Character Set</label>
<select name="char" class="custom-select w-full">
<option value="lower">abcd (Lower)</option>
<option value="upper">ABCD (Upper)</option>
<option value="uppernumber">ABCD2345 (Upper + Num)</option>
<option value="lowernumber">abcd2345 (Lower + Num)</option>
<option value="number">12345 (Numbers)</option>
<option value="mix">aBcD2345 (Mix)</option>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.characters_help">Character types to include.</p>
</div>
</div>
</div>
<!-- Limit Profile (Full Width) -->
<div class="space-y-6 pt-2">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2 mb-4" data-i18n="hotspot_generate.form.limits_profile">Limits & Profile</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Profile -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.profile">Profile</label>
<select name="profile" class="custom-select w-full" required data-search="true">
<?php foreach ($profiles as $profile): ?>
<option value="<?= htmlspecialchars($profile['name']) ?>">
<?= htmlspecialchars($profile['name']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.profile_help">Apply speed limits from profile.</p>
</div>
<!-- Empty Placeholder for Grid Alignment -->
<div class="hidden md:block"></div>
<!-- Time Limit -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.time_limit">Time Limit</label>
<div class="flex w-full">
<!-- Day -->
<div class="input-group flex-1">
<input type="number" name="timelimit_d" min="0" class="form-input w-full pr-8 rounded-r-none border-r-0 focus:z-10 font-mono text-center" placeholder="0">
<div class="input-suffix text-xs font-bold w-8 justify-center">D</div>
</div>
<!-- Hour -->
<div class="input-group flex-1">
<input type="number" name="timelimit_h" min="0" max="23" class="form-input w-full pr-8 rounded-none border-r-0 focus:z-10 font-mono text-center" placeholder="0">
<div class="input-suffix text-xs font-bold w-8 justify-center">H</div>
</div>
<!-- Minute -->
<div class="input-group flex-1">
<input type="number" name="timelimit_m" min="0" max="59" class="form-input w-full pr-8 rounded-l-none focus:z-10 font-mono text-center" placeholder="0">
<div class="input-suffix text-xs font-bold w-8 justify-center">M</div>
</div>
</div>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.time_limit_help">Max uptime (e.g. 1h, 30m).</p>
</div>
<!-- Data Limit -->
<div class="space-y-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_generate.form.data_limit">Data Limit</label>
<div class="flex w-full">
<div class="input-group flex-grow z-0 focus-within:z-10">
<div class="input-icon">
<i data-lucide="database" class="w-4 h-4"></i>
</div>
<input type="number" name="datalimit_val" min="0" class="form-input w-full rounded-r-none border-r-0" placeholder="0">
</div>
<select name="datalimit_unit" class="custom-select w-32 bg-accents-1 font-medium text-accents-6 text-center rounded-l-none border-l-0 -ml-px z-0 focus:z-10">
<option value="MB" selected>MB</option>
<option value="GB">GB</option>
</select>
</div>
<p class="text-xs text-accents-5" data-i18n="hotspot_generate.form.data_limit_help">Max data transfer (MB).</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="pt-6 border-t border-accents-2 flex justify-end gap-3">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="btn btn-secondary" data-i18n="common.cancel">Cancel</a>
<button type="submit" class="btn btn-primary px-8 shadow-lg shadow-primary/20 hover:shadow-primary/40 transition-shadow">
<i data-lucide="zap" class="w-4 h-4 mr-2"></i>
<span data-i18n="hotspot_generate.form.generate">Generate Vouchers</span>
</button>
</div>
</form>
</div>
</div>
<!-- Sticky Quick Tips Column -->
<div class="lg:col-span-1">
<div class="sticky top-6 space-y-6">
<div class="card p-6 bg-accents-1/50 border-accents-2 border-dashed">
<h3 class="font-semibold mb-4 flex items-center gap-2 text-foreground" data-i18n="hotspot_generate.form.quick_tips">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
Quick Tips
</h3>
<div class="space-y-6 text-sm text-accents-5">
<div class="space-y-2">
<h4 class="font-medium text-foreground" data-i18n="hotspot_generate.form.user_mode">User Mode</h4>
<ul class="space-y-1">
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_generate.form.tip_user_mode"><strong>User Mode</strong>: UP (separate), VC (same).</span>
</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-medium text-foreground" data-i18n="hotspot_generate.form.user_format">User Format</h4>
<ul class="space-y-1">
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_generate.form.tip_format_examples"><strong>Format Examples</strong>: abcd (lower), 1234 (num), Mix (upper/lower/num).</span>
</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-medium text-foreground" data-i18n="hotspot_profiles.form.limits_queues">Limits</h4>
<ul class="space-y-1">
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_generate.form.tip_limits"><strong>Limits</strong>: Time (e.g. 1h, 30m), Data (e.g. 100MB). Leave empty to use Profile default.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer closes the divs opened in sidebar.php -->
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Initialize Custom Selects with Search
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(select => {
new CustomSelect(select);
});
}
});
</script>

View File

@@ -0,0 +1,236 @@
<?php
$title = "Add User Profile";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="max-w-5xl mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="hotspot_profiles.form.add_title">Add Profile</h1>
<p class="text-accents-5" data-i18n="hotspot_profiles.form.subtitle" data-i18n-params='{"name": "<?= htmlspecialchars($session) ?>"}'>Create a new hotspot user profile for: <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profiles" class="btn btn-secondary w-full sm:w-auto justify-center" data-i18n="common.back">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> Back
</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Form Column -->
<div class="lg:col-span-2">
<div class="card p-6 border-accents-2 shadow-sm">
<h3 class="text-lg font-semibold mb-6 flex items-center gap-2">
<div class="p-2 bg-primary/10 rounded-lg text-primary">
<i data-lucide="settings" class="w-5 h-5"></i>
</div>
<span data-i18n="hotspot_profiles.form.settings">New Profile Settings</span>
</h3>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/profile/store" method="POST" class="space-y-6">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<!-- General Settings Section -->
<div class="space-y-4">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2" data-i18n="hotspot_profiles.form.general">General</h4>
<!-- Name -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6"><span data-i18n="common.name">Name</span> <span class="text-red-500">*</span></label>
<input type="text" name="name" required class="form-input w-full" data-i18n-placeholder="hotspot_profiles.form.name_placeholder" placeholder="e.g. 1Hour-Package">
</div>
<!-- Pools & Shared Users -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.address_pool">Address Pool</label>
<select name="address-pool" class="custom-select w-full" data-search="true">
<option value="none" data-i18n="common.forms.none">none</option>
<?php foreach ($pools as $pool): ?>
<?php if(isset($pool['name'])): ?>
<option value="<?= htmlspecialchars($pool['name']) ?>"><?= htmlspecialchars($pool['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.shared_users">Shared Users</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="users" class="w-4 h-4"></i>
</span>
<input type="number" name="shared-users" value="1" min="1" class="form-input pl-10 w-full" placeholder="1">
</div>
</div>
</div>
</div>
<!-- Limits Section -->
<div class="space-y-4">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2" data-i18n="hotspot_profiles.form.limits_queues">Limits & Queues</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Rate Limit -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.rate_limit">Rate Limit (Rx/Tx)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="activity" class="w-4 h-4"></i>
</span>
<input type="text" name="rate-limit" class="form-input pl-10 w-full" data-i18n-placeholder="hotspot_profiles.form.rate_limit_help" placeholder="e.g. 512k/1M">
</div>
</div>
<!-- Parent Queue -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.parent_queue">Parent Queue</label>
<select name="parent-queue" class="custom-select w-full" data-search="true">
<option value="none" data-i18n="common.forms.none">none</option>
<?php foreach ($queues as $q): ?>
<option value="<?= htmlspecialchars($q) ?>"><?= htmlspecialchars($q) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<!-- Pricing & Validity -->
<div class="space-y-4">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2" data-i18n="hotspot_profiles.form.pricing_validity">Pricing & Validity</h4>
<!-- Expired Mode -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.expired_mode">Expired Mode</label>
<select name="expired_mode" id="expired-mode" class="custom-select w-full">
<option value="none" data-i18n="common.forms.none" selected>none</option>
<option value="rem">Remove</option>
<option value="ntf">Notice</option>
<option value="remc">Remove & Record</option>
<option value="ntfc">Notice & Record</option>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_profiles.form.expired_mode_help">Action when validity expires.</p>
</div>
<!-- Validity (Hidden by default unless mode selected) -->
<div id="validity-group" class="hidden space-y-1 transition-all duration-300">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.validity">Validity</label>
<div class="flex w-full">
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">D</span>
<input type="number" name="validity_d" min="0" class="form-input w-full pr-8 rounded-r-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">H</span>
<input type="number" name="validity_h" min="0" class="form-input w-full pr-8 rounded-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">M</span>
<input type="number" name="validity_m" min="0" class="form-input w-full pr-8 rounded-l-none focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
</div>
<p class="text-xs text-accents-5" data-i18n="hotspot_profiles.form.validity_help">Days / Hours / Minutes</p>
</div>
<!-- Prices -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.price">Price (Rp)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="tag" class="w-4 h-4"></i>
</span>
<input type="number" name="price" class="form-input pl-10 w-full" placeholder="e.g. 5000">
</div>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.selling_price">Selling Price (Rp)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="shopping-bag" class="w-4 h-4"></i>
</span>
<input type="number" name="selling_price" class="form-input pl-10 w-full" placeholder="e.g. 7000">
</div>
</div>
</div>
<!-- Lock User -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.lock_user">Lock User</label>
<select name="lock_user" class="custom-select w-full">
<option value="Disable" data-i18n="common.forms.disabled">Disable</option>
<option value="Enable" data-i18n="common.forms.enabled">Enable</option>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_profiles.form.lock_user_help">Lock user to one specific MAC address.</p>
</div>
</div>
<div class="pt-6 border-t border-accents-2 flex justify-end gap-3">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profiles" class="btn btn-secondary" data-i18n="common.cancel">Cancel</a>
<button type="submit" class="btn btn-primary px-8 shadow-lg shadow-primary/20 hover:shadow-primary/40 transition-shadow">
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
<span data-i18n="hotspot_profiles.form.save">Save Profile</span>
</button>
</div>
</form>
</div>
</div>
<!-- Sticky Quick Tips Column -->
<div class="lg:col-span-1">
<div class="sticky top-6 space-y-6">
<div class="card p-6 bg-accents-1/50 border-accents-2 border-dashed">
<h3 class="font-semibold mb-4 flex items-center gap-2 text-foreground" data-i18n="hotspot_profiles.form.quick_tips">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
Quick Tips
</h3>
<ul class="text-sm text-accents-5 space-y-3">
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_profiles.form.tip_rate_limit"><strong>Rate Limit</strong>: Rx/Tx (Upload/Download). Example: <code>512k/1M</code></span>
</li>
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_profiles.form.tip_expired_mode"><strong>Expired Mode</strong>: Select 'Remove' or 'Notice' to enable Validity.</span>
</li>
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_profiles.form.tip_parent_queue"><strong>Parent Queue</strong>: Assigns users to a specific parent queue for bandwidth management.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Custom Select Init
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(select => {
new CustomSelect(select);
});
}
// Validity Toggle Logic
const modeSelect = document.getElementById('expired-mode');
const validityGroup = document.getElementById('validity-group');
function toggleValidity() {
if (!modeSelect || !validityGroup) return;
// Show validity ONLY if mode != none
if (modeSelect.value === 'none') {
validityGroup.classList.add('hidden');
} else {
validityGroup.classList.remove('hidden');
}
}
if (modeSelect) {
// Initial check
toggleValidity();
// Listen for changes
modeSelect.addEventListener('change', toggleValidity);
}
});
</script>

View File

@@ -0,0 +1,241 @@
<?php
$title = "Edit User Profile";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="max-w-5xl mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="hotspot_profiles.form.edit_title">Edit Profile</h1>
<p class="text-accents-5" data-i18n="hotspot_profiles.form.edit_subtitle" data-i18n-params='{"name": "<?= htmlspecialchars($profile['name'] ?? '') ?>"}'>Edit hotspot user profile: <span class="text-foreground font-medium"><?= htmlspecialchars($profile['name'] ?? '') ?></span></p>
</div>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profiles" class="btn btn-secondary w-full sm:w-auto justify-center" data-i18n="common.back">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> Back
</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Form Column -->
<div class="lg:col-span-2">
<div class="card p-6 border-accents-2 shadow-sm">
<h3 class="text-lg font-semibold mb-6 flex items-center gap-2">
<div class="p-2 bg-primary/10 rounded-lg text-primary">
<i data-lucide="edit" class="w-5 h-5"></i>
</div>
<span data-i18n="hotspot_profiles.form.edit_title">Edit Profile</span>
</h3>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/profile/update" method="POST" class="space-y-6">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($profile['.id']) ?>">
<!-- General Settings Section -->
<div class="space-y-4">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2" data-i18n="hotspot_profiles.form.general">General</h4>
<!-- Name -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6"><span data-i18n="common.name">Name</span> <span class="text-red-500">*</span></label>
<input type="text" name="name" value="<?= htmlspecialchars($profile['name'] ?? '') ?>" required class="form-input w-full" data-i18n-placeholder="hotspot_profiles.form.name_placeholder" placeholder="e.g. 1Hour-Package">
</div>
<!-- Pools & Shared Users -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.address_pool">Address Pool</label>
<select name="address-pool" class="custom-select w-full" data-search="true">
<option value="none" data-i18n="common.forms.none" <?= ($profile['address-pool'] ?? 'none') === 'none' ? 'selected' : '' ?>>none</option>
<?php foreach ($pools as $pool): ?>
<?php if(isset($pool['name'])): ?>
<option value="<?= htmlspecialchars($pool['name']) ?>" <?= ($profile['address-pool'] ?? '') === $pool['name'] ? 'selected' : '' ?>>
<?= htmlspecialchars($pool['name']) ?>
</option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.shared_users">Shared Users</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="users" class="w-4 h-4"></i>
</span>
<input type="number" name="shared-users" value="<?= htmlspecialchars($profile['shared-users'] ?? '1') ?>" min="1" class="form-input pl-10 w-full" placeholder="1">
</div>
</div>
</div>
</div>
<!-- Limits Section -->
<div class="space-y-4">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2" data-i18n="hotspot_profiles.form.limits_queues">Limits & Queues</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Rate Limit -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.rate_limit">Rate Limit (Rx/Tx)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="activity" class="w-4 h-4"></i>
</span>
<input type="text" name="rate-limit" value="<?= htmlspecialchars($profile['rate-limit'] ?? '') ?>" class="form-input pl-10 w-full" data-i18n-placeholder="hotspot_profiles.form.rate_limit_help" placeholder="e.g. 512k/1M">
</div>
</div>
<!-- Parent Queue -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.parent_queue">Parent Queue</label>
<select name="parent-queue" class="custom-select w-full" data-search="true">
<option value="none" data-i18n="common.forms.none" <?= ($profile['parent-queue'] ?? 'none') === 'none' ? 'selected' : '' ?>>none</option>
<?php foreach ($queues as $q): ?>
<option value="<?= htmlspecialchars($q) ?>" <?= ($profile['parent-queue'] ?? '') === $q ? 'selected' : '' ?>>
<?= htmlspecialchars($q) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<!-- Pricing & Validity -->
<div class="space-y-4">
<h4 class="text-xs font-bold text-accents-5 uppercase tracking-wider border-b border-accents-2 pb-2" data-i18n="hotspot_profiles.form.pricing_validity">Pricing & Validity</h4>
<!-- Expired Mode -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.expired_mode">Expired Mode</label>
<?php $exMode = $profile['meta']['expired_mode'] ?? 'none'; ?>
<select name="expired_mode" id="expired-mode" class="custom-select w-full">
<option value="none" data-i18n="common.forms.none" <?= ($exMode === 'none' || $exMode === '') ? 'selected' : '' ?>>none</option>
<option value="rem" <?= $exMode === 'rem' ? 'selected' : '' ?>>Remove</option>
<option value="ntf" <?= $exMode === 'ntf' ? 'selected' : '' ?>>Notice</option>
<option value="remc" <?= $exMode === 'remc' ? 'selected' : '' ?>>Remove & Record</option>
<option value="ntfc" <?= $exMode === 'ntfc' ? 'selected' : '' ?>>Notice & Record</option>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_profiles.form.expired_mode_help">Action when validity expires.</p>
</div>
<!-- Validity (Hidden by default unless mode selected) -->
<div id="validity-group" class="hidden space-y-1 transition-all duration-300">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.validity">Validity</label>
<div class="flex w-full">
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">D</span>
<input type="number" name="validity_d" value="<?= htmlspecialchars($profile['val_d'] ?? '') ?>" min="0" class="form-input w-full pr-8 rounded-r-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">H</span>
<input type="number" name="validity_h" value="<?= htmlspecialchars($profile['val_h'] ?? '') ?>" min="0" class="form-input w-full pr-8 rounded-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">M</span>
<input type="number" name="validity_m" value="<?= htmlspecialchars($profile['val_m'] ?? '') ?>" min="0" class="form-input w-full pr-8 rounded-l-none focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
</div>
<p class="text-xs text-accents-5" data-i18n="hotspot_profiles.form.validity_help">Days / Hours / Minutes</p>
</div>
<!-- Prices -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.price">Price (Rp)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="tag" class="w-4 h-4"></i>
</span>
<input type="number" name="price" value="<?= htmlspecialchars($profile['meta']['price'] ?? '') ?>" class="form-input pl-10 w-full" placeholder="e.g. 5000">
</div>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.selling_price">Selling Price (Rp)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="shopping-bag" class="w-4 h-4"></i>
</span>
<input type="number" name="selling_price" value="<?= htmlspecialchars($profile['meta']['selling_price'] ?? '') ?>" class="form-input pl-10 w-full" placeholder="e.g. 7000">
</div>
</div>
</div>
<!-- Lock User -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_profiles.form.lock_user">Lock User</label>
<?php $lock = $profile['meta']['lock_user'] ?? 'Disable'; ?>
<select name="lock_user" class="custom-select w-full">
<option value="Disable" data-i18n="common.forms.disabled" <?= $lock === 'Disable' ? 'selected' : '' ?>>Disable</option>
<option value="Enable" data-i18n="common.forms.enabled" <?= $lock === 'Enable' ? 'selected' : '' ?>>Enable</option>
</select>
<p class="text-xs text-accents-5" data-i18n="hotspot_profiles.form.lock_user_help">Lock user to one specific MAC address.</p>
</div>
</div>
<div class="pt-6 border-t border-accents-2 flex justify-end gap-3">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profiles" class="btn btn-secondary" data-i18n="common.cancel">Cancel</a>
<button type="submit" class="btn btn-primary px-8 shadow-lg shadow-primary/20 hover:shadow-primary/40 transition-shadow">
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
<span data-i18n="common.forms.save_changes">Save Changes</span>
</button>
</div>
</form>
</div>
</div>
<!-- Sticky Quick Tips Column -->
<div class="lg:col-span-1">
<div class="sticky top-6 space-y-6">
<div class="card p-6 bg-accents-1/50 border-accents-2 border-dashed">
<h3 class="font-semibold mb-4 flex items-center gap-2 text-foreground" data-i18n="hotspot_profiles.form.quick_tips">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
Quick Tips
</h3>
<ul class="text-sm text-accents-5 space-y-3">
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_profiles.form.tip_rate_limit"><strong>Rate Limit</strong>: Rx/Tx (Upload/Download). Example: <code>512k/1M</code></span>
</li>
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_profiles.form.tip_expired_mode"><strong>Expired Mode</strong>: Select 'Remove' or 'Notice' to enable Validity.</span>
</li>
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_profiles.form.tip_parent_queue"><strong>Parent Queue</strong>: Assigns users to a specific parent queue for bandwidth management.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Custom Select Init
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(select => {
new CustomSelect(select);
});
}
// Validity Toggle Logic
const modeSelect = document.getElementById('expired-mode');
const validityGroup = document.getElementById('validity-group');
function toggleValidity() {
if (!modeSelect || !validityGroup) return;
// Show validity ONLY if mode != none
if (modeSelect.value === 'none') {
validityGroup.classList.add('hidden');
} else {
validityGroup.classList.remove('hidden');
}
}
// Initial check
toggleValidity();
// Listen for changes
modeSelect.addEventListener('change', toggleValidity);
});
</script>

View File

@@ -0,0 +1,311 @@
<?php
$title = "User Profiles";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Prepare Filters Data
$uniqueModes = [];
if (!empty($profiles)) {
foreach ($profiles as $p) {
$m = $p['meta']['expired_mode_formatted'] ?? '';
if(!empty($m)) $uniqueModes[$m] = $m;
}
}
sort($uniqueModes);
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="hotspot_profiles.title">User Profiles</h1>
<p class="text-accents-5"><span data-i18n="hotspot_profiles.subtitle">Manage hotspot rate limits and pricing for session</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/add" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_profiles.add_profile">Add Profile</span>
</a>
</div>
</div>
<?php if (isset($error)): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<!-- Filters & Table -->
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="input-group md:w-64 z-10">
<div class="input-icon">
<i data-lucide="search" class="h-4 w-4"></i>
</div>
<input type="text" id="global-search" class="form-input-search w-full" placeholder="Search profile...">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-48">
<select id="filter-mode" class="custom-select form-filter" data-search="true">
<option value="" data-i18n="hotspot_profiles.all_modes">All Expired Modes</option>
<?php foreach($uniqueModes as $m): ?>
<option value="<?= htmlspecialchars($m) ?>"><?= htmlspecialchars($m) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="table-container">
<table class="table-glass" id="profiles-table">
<thead>
<tr>
<th data-sort="name" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_profiles.name">Name</th>
<th data-i18n="hotspot_profiles.shared_users">Shared Users</th>
<th data-i18n="hotspot_profiles.rate_limit">Rate Limit</th>
<th data-i18n="hotspot_profiles.parent_queue">Parent Queue</th>
<th data-sort="mode" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_profiles.expired_mode">Expired Mode</th>
<th data-i18n="hotspot_profiles.validity">Validity</th>
<th data-i18n="hotspot_profiles.price">Price</th>
<th data-i18n="hotspot_profiles.selling_price">Selling Price</th>
<th data-i18n="hotspot_profiles.lock_user">Lock User</th>
<th class="text-right" data-i18n="common.actions">Actions</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($profiles)): ?>
<?php foreach ($profiles as $profile): ?>
<tr class="table-row-item"
data-name="<?= strtolower($profile['name'] ?? '') ?>"
data-mode="<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>">
<td>
<div class="flex items-center">
<div class="h-8 w-8 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 flex items-center justify-center text-xs font-bold mr-3">
<i data-lucide="ticket" class="w-4 h-4"></i>
</div>
<div class="text-sm font-medium text-foreground">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400">
<?= htmlspecialchars($profile['name'] ?? '-') ?>
</a>
</div>
</div>
</td>
<td>
<span class="text-sm font-semibold"><?= htmlspecialchars($profile['shared-users'] ?? '1') ?></span>
<span class="text-xs text-accents-5">dev</span>
</td>
<td>
<?php if(!empty($profile['rate-limit'])): ?>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<?= htmlspecialchars($profile['rate-limit']) ?>
</span>
<?php else: ?>
<span class="text-xs text-accents-4">-</span>
<?php endif; ?>
</td>
<td class="text-sm text-accents-6">
<?= htmlspecialchars($profile['parent-queue'] ?? '-') ?>
</td>
<td class="text-sm text-accents-6">
<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>
</td>
<td class="text-sm text-accents-6">
<?= htmlspecialchars($profile['meta']['validity'] ?? '') ?>
</td>
<td class="text-sm text-accents-6">
<?= htmlspecialchars($profile['meta']['price'] ?? '') ?>
</td>
<td class="text-sm text-accents-6">
<?= htmlspecialchars($profile['meta']['selling_price'] ?? '') ?>
</td>
<td class="text-sm text-accents-6">
<?= htmlspecialchars($profile['meta']['lock_user'] ?? '') ?>
</td>
<td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="btn bg-blue-50 hover:bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i>
</a>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/profile/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('hotspot_profiles.title') : 'Delete Profile?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete profile <?= $profile['name'] ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $profile['.id'] ?>">
<button type="submit" class="btn bg-red-50 hover:bg-red-100 text-red-600 dark:bg-red-900/20 dark:hover:bg-red-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> profiles
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = {
search: '',
mode: ''
};
this.init();
}
init() {
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.setupListeners();
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
setupListeners() {
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => {
if (this.currentPage > 1) {
this.currentPage--;
this.render();
}
});
this.elements.nextBtn.addEventListener('click', () => {
const maxPage = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if (this.currentPage < maxPage) {
this.currentPage++;
this.render();
}
});
document.getElementById('filter-mode').addEventListener('change', (e) => {
this.filters.mode = e.target.value;
this.currentPage = 1;
this.update();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const name = row.dataset.name || '';
const mode = row.dataset.mode || '';
if (this.filters.search && !name.includes(this.filters.search)) return false;
if (this.filters.mode && mode !== this.filters.mode) return false;
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
const pageRows = this.filteredRows.slice(start, end);
pageRows.forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(select => {
new CustomSelect(select);
});
}
const rows = document.querySelectorAll('.table-row-item');
new TableManager(rows, 10);
});
</script>

View File

@@ -0,0 +1,171 @@
<?php
$title = "Add User";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="max-w-5xl mx-auto">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="hotspot_users.form.add_title">Add User</h1>
<p class="text-accents-5" data-i18n="hotspot_users.form.subtitle" data-i18n-params='{"name": "<?= htmlspecialchars($session) ?>"}'>Generate a new voucher/user for session: <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="btn btn-secondary" data-i18n="common.back">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> Back to List
</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 space-y-6">
<div class="card p-6 border-accents-2 shadow-sm">
<h3 class="text-lg font-semibold mb-6 flex items-center gap-2">
<div class="p-2 bg-primary/10 rounded-lg text-primary">
<i data-lucide="user-plus" class="w-5 h-5"></i>
</div>
<span data-i18n="hotspot_users.form.subtitle">User Details</span>
</h3>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/store" method="POST" class="space-y-6">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Name & Password -->
<div class="space-y-2">
<label class="text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.username">Name (Username)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="user" class="w-4 h-4"></i>
</span>
<input type="text" name="name" required class="form-input pl-10 w-full focus:ring-2 focus:ring-primary/20 transition-all" data-i18n-placeholder="hotspot_users.form.username_placeholder" placeholder="e.g. voucher123">
</div>
<p class="text-xs text-accents-5 mt-1" data-i18n="hotspot_users.form.username_help">Unique username for login.</p>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.password">Password</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="key" class="w-4 h-4"></i>
</span>
<input type="text" name="password" required class="form-input pl-10 w-full focus:ring-2 focus:ring-primary/20 transition-all" data-i18n-placeholder="hotspot_users.form.password_placeholder" placeholder="e.g. 123456">
</div>
<p class="text-xs text-accents-5 mt-1" data-i18n="hotspot_users.form.password_help">Strong password for security.</p>
</div>
<!-- Profile -->
<div class="space-y-2 col-span-1 md:col-span-2">
<label class="text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.profile">Profile</label>
<!-- Searchable Dropdown -->
<select name="profile" class="custom-select w-full" data-search="true">
<?php foreach ($profiles as $profile): ?>
<?php if(!empty($profile['name'])): ?>
<option value="<?= htmlspecialchars($profile['name']) ?>"><?= htmlspecialchars($profile['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<p class="text-xs text-accents-4 mt-1" data-i18n="hotspot_users.form.profile_help">Profile determines speed limit and shared user policy.</p>
</div>
<!-- Time Limit (Split) -->
<div class="space-y-2">
<label class="text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.time_limit">Time Limit</label>
<div class="flex w-full">
<!-- Day -->
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">D</span>
<input type="number" name="timelimit_d" min="0" class="form-input w-full pr-8 rounded-r-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<!-- Hour -->
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">H</span>
<input type="number" name="timelimit_h" min="0" max="23" class="form-input w-full pr-8 rounded-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<!-- Minute -->
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-6 z-10 text-xs font-bold pointer-events-none">M</span>
<input type="number" name="timelimit_m" min="0" max="59" class="form-input w-full pr-8 rounded-l-none focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
</div>
<p class="text-xs text-accents-5 mt-1" data-i18n="hotspot_users.form.time_limit_help">Total allowed uptime (Days, Hours, Minutes).</p>
</div>
<!-- Data Limit (Unit) -->
<div class="space-y-2">
<label class="text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.data_limit">Data Limit</label>
<div class="flex relative w-full">
<div class="relative flex-grow z-0 focus-within:z-10">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 transition-colors pointer-events-none">
<i data-lucide="database" class="w-4 h-4"></i>
</span>
<input type="number" name="datalimit_val" min="0" class="form-input w-full pl-10 rounded-r-none focus:ring-2 focus:ring-primary/20 transition-all" placeholder="0">
</div>
<div class="relative -ml-px w-24 z-0 focus-within:z-10">
<select name="datalimit_unit" class="custom-select form-input w-full rounded-l-none bg-accents-1 focus:ring-2 focus:ring-primary/20 cursor-pointer font-medium text-accents-6 text-center">
<option value="MB" selected>MB</option>
<option value="GB">GB</option>
</select>
</div>
</div>
<p class="text-xs text-accents-5 mt-1" data-i18n="hotspot_users.form.data_limit_help">Limit data usage (0 for unlimited).</p>
</div>
<!-- Comment -->
<div class="space-y-2 col-span-1 md:col-span-2">
<label class="text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.comment">Comment</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-6 z-10 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="message-square" class="w-4 h-4"></i>
</span>
<input type="text" name="comment" class="form-input pl-10 w-full focus:ring-2 focus:ring-primary/20 transition-all" data-i18n-placeholder="hotspot_users.form.comment_placeholder" placeholder="Optional note for this user">
</div>
<p class="text-xs text-accents-5 mt-1" data-i18n="hotspot_users.form.comment_help">Additional notes or contact info.</p>
</div>
</div>
<div class="pt-6 border-t border-accents-2 flex justify-end gap-3">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="btn btn-secondary" data-i18n="common.cancel">Cancel</a>
<button type="submit" class="btn btn-primary px-8 shadow-lg shadow-primary/20 hover:shadow-primary/40 transition-shadow">
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
<span data-i18n="hotspot_users.form.save">Save User</span>
</button>
</div>
</form>
</div>
</div>
<!-- Quick Help / Info -->
<div class="space-y-6">
<div class="card p-6 bg-accents-1/50 border-accents-2 border-dashed">
<h3 class="font-semibold mb-4 flex items-center gap-2 text-foreground" data-i18n="hotspot_users.form.quick_tips">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
Quick Tips
</h3>
<ul class="text-sm text-accents-5 space-y-3">
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_users.form.tip_profiles"><strong>Profiles</strong> define the default speed limits, session timeout, and shared users policy.</span>
</li>
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_users.form.tip_time_limit"><strong>Time Limit</strong> is the total accumulated uptime allowed for this user.</span>
</li>
<li class="flex gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 flex-shrink-0"></span>
<span data-i18n="hotspot_users.form.tip_data_limit"><strong>Data Limit</strong> will override the profile's data limit settings if specified here. Set to 0 to use profile default.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Initialize Custom Selects with Search
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(select => {
new CustomSelect(select);
});
}
});
</script>

View File

@@ -0,0 +1,134 @@
<?php require_once ROOT . '/app/Views/layouts/header_main.php'; ?>
<?php require_once ROOT . '/app/Views/layouts/sidebar_session.php'; ?>
<!-- Content Inside max-w-7xl -->
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-foreground" data-i18n="hotspot_users.form.edit_title">Edit Hotspot User</h1>
<p class="text-sm text-accents-5" data-i18n="hotspot_users.form.edit_subtitle" data-i18n-params='{"name": "<?= htmlspecialchars($user['name']) ?>"}'>Update user details for: <span class="font-medium text-foreground"><?= htmlspecialchars($user['name']) ?></span></p>
</div>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="btn btn-secondary w-full sm:w-auto justify-center" data-i18n="common.back">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
Cancel
</a>
</div>
<div class="card bg-background border border-accents-2 rounded-lg shadow-sm">
<form action="/<?= htmlspecialchars($session) ?>/hotspot/update" method="POST" class="p-6 space-y-6">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($user['.id']) ?>">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Username -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.username">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="user" class="w-4 h-4 text-accents-4"></i>
</div>
<input type="text" name="name" class="form-input pl-10 w-full"
value="<?= htmlspecialchars($user['name'] ?? '') ?>" required>
</div>
</div>
<!-- Password -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.password">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-4 h-4 text-accents-4"></i>
</div>
<input type="text" name="password" class="form-input pl-10 w-full"
value="<?= htmlspecialchars($user['password'] ?? '') ?>">
</div>
</div>
<!-- Profile -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.profile">Profile</label>
<select name="profile" class="custom-select w-full">
<?php foreach ($profiles as $profile): ?>
<option value="<?= htmlspecialchars($profile['name']) ?>"
<?= (isset($user['profile']) && $user['profile'] === $profile['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($profile['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Server -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.server">Server</label>
<select name="server" class="custom-select w-full">
<option value="all" <?= (isset($user['server']) && $user['server'] === 'all') ? 'selected' : '' ?>>all</option>
<!-- Ideally fetch servers like in generate, but keeping it simple for now -->
</select>
</div>
<!-- Time Limit -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.time_limit">Time Limit</label>
<div class="flex w-full">
<!-- Day -->
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-4 text-xs font-bold pointer-events-none">D</span>
<input type="number" name="timelimit_d" value="<?= htmlspecialchars($user['time_d'] ?? '') ?>" min="0" class="form-input w-full pr-8 rounded-r-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<!-- Hour -->
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-4 text-xs font-bold pointer-events-none">H</span>
<input type="number" name="timelimit_h" value="<?= htmlspecialchars($user['time_h'] ?? '') ?>" min="0" max="23" class="form-input w-full pr-8 rounded-none border-r-0 focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
<!-- Minute -->
<div class="relative flex-1 group">
<span class="absolute right-3 top-2.5 text-accents-4 text-xs font-bold pointer-events-none">M</span>
<input type="number" name="timelimit_m" value="<?= htmlspecialchars($user['time_m'] ?? '') ?>" min="0" max="59" class="form-input w-full pr-8 rounded-l-none focus:ring-2 focus:ring-primary/20 focus:z-10 transition-all font-mono text-center" placeholder="0">
</div>
</div>
</div>
<!-- Data Limit -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.data_limit">Data Limit</label>
<div class="flex relative w-full">
<div class="relative flex-grow z-0 focus-within:z-10">
<span class="absolute left-3 top-2.5 text-accents-4 transition-colors pointer-events-none">
<i data-lucide="database" class="w-4 h-4"></i>
</span>
<input type="number" name="datalimit_val" value="<?= htmlspecialchars($user['data_val'] ?? '') ?>" min="0" class="form-input w-full pl-10 rounded-r-none focus:ring-2 focus:ring-primary/20 transition-all" placeholder="0">
</div>
<div class="relative -ml-px w-24 z-0 focus-within:z-10">
<select name="datalimit_unit" class="custom-select form-input w-full rounded-l-none bg-accents-1 focus:ring-2 focus:ring-primary/20 cursor-pointer font-medium text-accents-6 text-center">
<option value="MB" <?= ($user['data_unit'] ?? 'MB') === 'MB' ? 'selected' : '' ?>>MB</option>
<option value="GB" <?= ($user['data_unit'] ?? 'MB') === 'GB' ? 'selected' : '' ?>>GB</option>
</select>
</div>
</div>
</div>
<!-- Comment -->
<div class="space-y-1 col-span-2">
<label class="block text-sm font-medium text-accents-6" data-i18n="hotspot_users.form.comment">Comment</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="message-square" class="w-4 h-4 text-accents-4"></i>
</div>
<input type="text" name="comment" class="form-input pl-10 w-full"
value="<?= htmlspecialchars($user['comment'] ?? '') ?>">
</div>
</div>
</div>
<!-- Actions -->
<div class="pt-6 border-t border-accents-2 flex justify-end gap-3">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="btn btn-secondary" data-i18n="common.cancel">Cancel</a>
<button type="submit" class="btn btn-primary">
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
<span data-i18n="common.forms.save_changes">Save Changes</span>
</button>
</div>
</form>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,449 @@
<?php
$title = "Hotspot Users";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Prepare Filters Data
$uniqueProfiles = [];
$uniqueComments = [];
if (!empty($users)) {
foreach ($users as $u) {
$p = $u['profile'] ?? 'default';
$c = $u['comment'] ?? '';
$uniqueProfiles[$p] = $p; // Key-Value distinct
if(!empty($c)) $uniqueComments[$c] = $c;
}
}
sort($uniqueProfiles);
sort($uniqueComments);
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="hotspot_users.title">Hotspot Users</h1>
<p class="text-accents-5"><span data-i18n="hotspot_users.subtitle">Manage vouchers and user accounts for session</span>: <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/add" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_users.add_user">Add User</span>
</a>
</div>
</div>
<?php if ($error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<!-- Batch Action Toolbar -->
<div id="batch-toolbar" class="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-foreground text-background px-6 py-3 rounded-full shadow-lg z-50 flex items-center gap-4 transition-all duration-300 translate-y-20 opacity-0">
<span class="text-sm font-medium"><span id="selected-count">0</span> <span data-i18n="common.selected">Selected</span></span>
<div class="h-4 w-px bg-background/20"></div>
<button onclick="printSelected()" class="flex items-center gap-2 hover:text-accents-2 transition-colors font-bold text-sm">
<i data-lucide="printer" class="w-4 h-4"></i> <span data-i18n="common.print">Print</span>
</button>
<button onclick="deleteSelected()" class="flex items-center gap-2 text-red-400 hover:text-red-300 transition-colors font-bold text-sm">
<i data-lucide="trash-2" class="w-4 h-4"></i> <span data-i18n="common.delete">Delete</span>
</button>
</div>
<!-- Filters & Table -->
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="input-group md:w-64 z-10">
<div class="input-icon">
<i data-lucide="search" class="h-4 w-4"></i>
</div>
<input type="text" id="global-search" class="form-input-search w-full" placeholder="Search user..." data-i18n="common.table.search_placeholder">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-40">
<select id="filter-profile" class="custom-select form-filter" data-search="true">
<option value="" data-i18n="common.all_profiles">All Profiles</option>
<?php foreach($uniqueProfiles as $p): ?>
<option value="<?= htmlspecialchars($p) ?>"><?= htmlspecialchars($p) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="w-40">
<select id="filter-comment" class="custom-select form-filter" data-search="true">
<option value="" data-i18n="common.all_comments">All Comments</option>
<?php foreach($uniqueComments as $c): ?>
<option value="<?= htmlspecialchars($c) ?>"><?= htmlspecialchars($c) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<!-- Table Container -->
<!-- Table Container -->
<div class="table-container">
<table class="table-glass" id="users-table">
<thead>
<tr>
<th scope="col" class="px-4 py-3 w-10">
<input type="checkbox" id="select-all" class="checkbox">
</th>
<th data-sort="name" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_users.name">Name</th>
<th data-sort="profile" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_users.profile">Profile</th>
<th data-i18n="hotspot_users.uptime_limit">Uptime / Limit</th>
<th data-i18n="hotspot_users.bytes_in_out">Bytes In/Out</th>
<th data-sort="comment" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_users.comment">Comment</th>
<th class="relative text-right" data-i18n="common.actions">
Actions
</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($users)): ?>
<?php foreach ($users as $user): ?>
<tr class="table-row-item"
data-name="<?= strtolower($user['name'] ?? '') ?>"
data-profile="<?= $user['profile'] ?? 'default' ?>"
data-comment="<?= htmlspecialchars($user['comment'] ?? '') ?>">
<td class="px-4 py-4">
<input type="checkbox" name="selected_users[]" value="<?= htmlspecialchars($user['.id']) ?>" class="user-checkbox checkbox">
</td>
<td>
<div class="flex items-center w-full">
<div class="h-8 w-8 rounded bg-accents-2 flex items-center justify-center text-xs font-bold mr-3 text-accents-6 flex-shrink-0">
<i data-lucide="user" class="w-4 h-4"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-4">
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($user['name'] ?? '-') ?></div>
<?php
$status = \App\Helpers\HotspotHelper::getUserStatus($user);
echo \App\Helpers\ViewHelper::badge($status);
?>
</div>
<div class="text-xs text-accents-5"><?= htmlspecialchars($user['password'] ?? '******') ?></div>
</div>
</div>
</td>
<td>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<?= htmlspecialchars($user['profile'] ?? 'default') ?>
</span>
</td>
<td>
<div class="text-sm text-foreground"><?= \App\Helpers\FormatHelper::elapsedTime($user['uptime'] ?? '0s') ?></div>
<div class="text-xs text-accents-5">Limit: <?= \App\Helpers\FormatHelper::elapsedTime($user['limit-uptime'] ?? 'unlimited') ?></div>
</td>
<td>
<div class="text-xs text-accents-5 flex flex-col gap-1">
<span class="flex items-center"><i data-lucide="arrow-down" class="w-3 h-3 mr-1 text-green-500"></i> <?= \App\Helpers\FormatHelper::formatBytes($user['bytes-in'] ?? 0) ?></span>
<span class="flex items-center"><i data-lucide="arrow-up" class="w-3 h-3 mr-1 text-blue-500"></i> <?= \App\Helpers\FormatHelper::formatBytes($user['bytes-out'] ?? 0) ?></span>
</div>
</td>
<td>
<div class="text-sm text-accents-5 italic"><?= htmlspecialchars($user['comment'] ?? '-') ?></div>
</td>
<td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal">
<button onclick="printUser('<?= htmlspecialchars($user['.id']) ?>')" class="btn-icon" title="Print">
<i data-lucide="printer" class="w-4 h-4"></i>
</button>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/user/edit/<?= urlencode($user['.id']) ?>" class="btn-icon inline-flex items-center justify-center" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i>
</a>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Delete User?', 'Are you sure you want to delete user <?= htmlspecialchars($user['name'] ?? '') ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $user['.id'] ?>">
<button type="submit" class="btn-icon-danger" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
<span id="pagination-text">Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> users</span>
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = {
search: '',
profile: '',
comment: ''
};
this.init();
}
init() {
this.setupListeners();
this.update();
}
setupListeners() {
// Search Input
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
// Prev/Next
this.elements.prevBtn.addEventListener('click', () => {
if (this.currentPage > 1) {
this.currentPage--;
this.render();
}
});
this.elements.nextBtn.addEventListener('click', () => {
const maxPage = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if (this.currentPage < maxPage) {
this.currentPage++;
this.render();
}
});
// Custom Select Listener (Mutation Observer or custom event if we emitted one,
// but for now relying on underlying SELECT change or custom-select class behavior)
// Since CustomSelect updates the original Select, we listen to change on original select
document.getElementById('filter-profile').addEventListener('change', (e) => {
this.filters.profile = e.target.value;
this.currentPage = 1;
this.update();
});
document.getElementById('filter-comment').addEventListener('change', (e) => {
this.filters.comment = e.target.value;
this.currentPage = 1;
this.update();
});
// Re-bind actions when external CustomSelect updates the select value
// CustomSelect triggers 'change' event on original select, so standard listener works!
// Listen for language change to update pagination text
window.addEventListener('languageChanged', () => {
this.render();
});
}
update() {
// Apply Filters
this.filteredRows = this.allRows.filter(row => {
const name = row.dataset.name || '';
const comment = (row.dataset.comment || '').toLowerCase(); // dataset comment value
const profile = row.dataset.profile || '';
// 1. Search (Name or Comment)
if (this.filters.search) {
const matchName = name.includes(this.filters.search);
const matchComment = comment.includes(this.filters.search);
if (!matchName && !matchComment) return false;
}
// 2. Profile
if (this.filters.profile && profile !== this.filters.profile) return false;
// 3. Comment (Exact match for dropdown)
if (this.filters.comment && row.dataset.comment !== this.filters.comment) return false;
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
// Update Text (Use Translation)
if (window.i18n) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
document.getElementById('pagination-text').textContent = text;
} else {
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
}
// Clear & Append Rows
this.elements.body.innerHTML = '';
const pageRows = this.filteredRows.slice(start, end);
pageRows.forEach(row => this.elements.body.appendChild(row));
// Update Buttons
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
// Re-init Icons for new rows
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Update Checkbox Logic (Select All should act on visible?)
// We usually reset "Select All" check when page changes
document.getElementById('select-all').checked = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
// Init Custom Selects
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(select => {
new CustomSelect(select);
});
}
// Init Table
const rows = document.querySelectorAll('.table-row-item');
const manager = new TableManager(rows, 10);
// --- Toolbar Logic (Copied/Adapted) ---
const selectAll = document.getElementById('select-all');
const toolbar = document.getElementById('batch-toolbar');
const countSpan = document.getElementById('selected-count');
const tableBody = document.getElementById('table-body'); // Dynamic body
function updateToolbar() {
const checked = document.querySelectorAll('.user-checkbox:checked');
countSpan.textContent = checked.length;
if (checked.length > 0) {
toolbar.classList.remove('translate-y-20', 'opacity-0');
} else {
toolbar.classList.add('translate-y-20', 'opacity-0');
}
}
selectAll.addEventListener('change', (e) => {
const isChecked = e.target.checked;
// Only select visible rows on current page
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
visibleCheckboxes.forEach(cb => cb.checked = isChecked);
updateToolbar();
});
// Event Delegation for dynamic rows
tableBody.addEventListener('change', (e) => {
if (e.target.classList.contains('user-checkbox')) {
updateToolbar();
if (!e.target.checked) selectAll.checked = false;
}
});
});
// Actions
function printUser(id) {
const width = 400;
const height = 600;
const left = (window.innerWidth - width) / 2;
const top = (window.innerHeight - height) / 2;
const session = '<?= htmlspecialchars($session) ?>';
const url = `/${session}/hotspot/print/${encodeURIComponent(id)}`;
window.open(url, `PrintUser`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
}
function printSelected() {
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return alert(window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "No users selected.");
const width = 800;
const height = 600;
const left = (window.innerWidth - width) / 2;
const top = (window.innerHeight - height) / 2;
const session = '<?= htmlspecialchars($session) ?>';
const ids = selected.map(id => encodeURIComponent(id)).join(',');
const url = `/${session}/hotspot/print-batch?ids=${ids}`;
window.open(url, `PrintBatch`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
}
function deleteSelected() {
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return alert(window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "Please select at least one user.");
const title = window.i18n ? window.i18n.t('common.delete') : 'Delete Users?';
const msg = window.i18n ? window.i18n.t('common.confirm_delete') : `Are you sure you want to delete ${selected.length} users?`;
Mivo.confirm(title, msg, window.i18n.t('common.delete'), window.i18n.t('common.cancel')).then(res => {
if (!res) return;
// Create a form to submit
const form = document.createElement('form');
form.method = 'POST';
form.action = '/<?= htmlspecialchars($session) ?>/hotspot/delete'; // Re-uses the delete endpoint
const sessionInput = document.createElement('input');
sessionInput.type = 'hidden';
sessionInput.name = 'session';
sessionInput.value = '<?= htmlspecialchars($session) ?>';
form.appendChild(sessionInput);
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = 'id';
idInput.value = selected.join(','); // Comma separated IDs
form.appendChild(idInput);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
});
}
</script>