mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 13:31:56 +07:00
Initial Release v1.0.0: Full feature set with Docker automation, Nginx/Alpine stack
This commit is contained in:
220
app/Views/settings/api_cors.php
Normal file
220
app/Views/settings/api_cors.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
$title = "API CORS";
|
||||
$no_main_container = true;
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<!-- Sub-Navbar Navigation -->
|
||||
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.api_cors_title">API CORS</h1>
|
||||
<p class="text-accents-5 mt-2" data-i18n="settings.api_cors_subtitle">Manage Cross-Origin Resource Sharing for API access.</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<div class="hidden md:block">
|
||||
<!-- Spacer -->
|
||||
</div>
|
||||
<div class="flex gap-2 w-full md:w-auto">
|
||||
<button onclick="openModal('addModal')" class="btn btn-primary w-full md:w-auto">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.add_rule">Add CORS Rule</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table-glass" id="cors-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="settings.origin">Origin</th>
|
||||
<th data-i18n="settings.methods">Allowed Methods</th>
|
||||
<th data-i18n="settings.headers">Allowed Headers</th>
|
||||
<th class="text-right" data-i18n="common.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<?php if (!empty($rules)): ?>
|
||||
<?php foreach ($rules as $rule): ?>
|
||||
<tr class="table-row-item">
|
||||
<td>
|
||||
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($rule['origin']) ?></div>
|
||||
<div class="text-xs text-accents-4">Max Age: <?= $rule['max_age'] ?>s</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<?php foreach ($rule['methods_arr'] as $method): ?>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"><?= htmlspecialchars($method) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm text-accents-5 truncate max-w-[200px]"><?= htmlspecialchars(implode(', ', $rule['headers_arr'])) ?></div>
|
||||
</td>
|
||||
<td class="text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||
<button onclick="editRule(<?= htmlspecialchars(json_encode($rule)) ?>)" class="btn-icon" title="Edit">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<form action="/settings/api-cors/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.cors_rule_deleted') : 'Delete CORS Rule?', 'Are you sure you want to delete the CORS rule for <?= htmlspecialchars($rule['origin']) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="id" value="<?= $rule['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 else: ?>
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<i data-lucide="shield" class="w-12 h-12 text-accents-2 mb-4"></i>
|
||||
<p class="text-accents-5">No CORS rules found. Add your first origin to allow external API access.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<div id="addModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('addModal')"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
||||
<div class="card shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold" data-i18n="settings.add_rule">Add CORS Rule</h3>
|
||||
<button onclick="closeModal('addModal')" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form action="/settings/api-cors/store" method="POST" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
|
||||
<input type="text" name="origin" class="form-control" placeholder="https://example.com or *" required>
|
||||
<p class="text-xs text-orange-500 dark:text-orange-400 mt-1 font-medium">Use * for all origins (not recommended for production).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox" <?= in_array($m, ['GET', 'POST']) ? 'checked' : '' ?>>
|
||||
<span class="text-sm"><?= $m ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
|
||||
<input type="text" name="headers" class="form-control" value="*" placeholder="Content-Type, Authorization, *">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
|
||||
<input type="number" name="max_age" class="form-control" value="3600">
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div id="editModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('editModal')"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
||||
<div class="card shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold" data-i18n="settings.edit_rule">Edit CORS Rule</h3>
|
||||
<button onclick="closeModal('editModal')" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form action="/settings/api-cors/update" method="POST" class="space-y-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
|
||||
<input type="text" name="origin" id="edit_origin" class="form-control" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
|
||||
<div class="grid grid-cols-3 gap-2" id="edit_methods_container">
|
||||
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox edit-method-check" data-method="<?= $m ?>">
|
||||
<span class="text-sm"><?= $m ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
|
||||
<input type="text" name="headers" id="edit_headers" class="form-control">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
|
||||
<input type="number" name="max_age" id="edit_max_age" class="form-control">
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
const content = modal.querySelector('.modal-content');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Use double requestAnimationFrame to ensure the browser has painted the hidden->block change
|
||||
// before we trigger the opacity/transform transitions.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.remove('opacity-0');
|
||||
content.classList.remove('scale-95', 'opacity-0');
|
||||
content.classList.add('scale-100', 'opacity-100');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
const content = modal.querySelector('.modal-content');
|
||||
modal.classList.add('opacity-0');
|
||||
content.classList.remove('scale-100', 'opacity-100');
|
||||
content.classList.add('scale-95', 'opacity-0');
|
||||
setTimeout(() => { modal.classList.add('hidden'); }, 300);
|
||||
}
|
||||
|
||||
function editRule(rule) {
|
||||
document.getElementById('edit_id').value = rule.id;
|
||||
document.getElementById('edit_origin').value = rule.origin;
|
||||
document.getElementById('edit_headers').value = rule.headers_arr.join(', ');
|
||||
document.getElementById('edit_max_age').value = rule.max_age;
|
||||
|
||||
// Clear and check checkboxes
|
||||
const methods = rule.methods_arr;
|
||||
document.querySelectorAll('.edit-method-check').forEach(cb => {
|
||||
cb.checked = methods.includes(cb.dataset.method);
|
||||
});
|
||||
|
||||
openModal('editModal');
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
123
app/Views/settings/form.php
Normal file
123
app/Views/settings/form.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
// Use $router variable instead of $session to avoid conflict with header.php logic
|
||||
$router = $router ?? null;
|
||||
$title = $router ? "Edit Router" : "Add Router";
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
|
||||
// Safe access helper
|
||||
$val = function($key) use ($router) {
|
||||
return isset($router) && isset($router[$key]) ? htmlspecialchars($router[$key]) : '';
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="w-full max-w-5xl mx-auto mb-16">
|
||||
<div class="mb-8">
|
||||
<a href="/settings/routers" class="inline-flex items-center text-sm text-accents-5 hover:text-foreground transition-colors mb-4">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> Back to Settings
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold tracking-tight"><?= $title ?></h1>
|
||||
<p class="text-accents-5">Connect Mikhmon to your RouterOS device.</p>
|
||||
</div>
|
||||
|
||||
<form autocomplete="off" method="post" action="<?= isset($router) ? '/settings/update' : '/settings/store' ?>">
|
||||
<?php if(isset($router)): ?>
|
||||
<input type="hidden" name="id" value="<?= $router['id'] ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card p-6 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold mb-4">Session Settings</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Session Name</label>
|
||||
<input class="form-control w-full" type="text" name="sessname" id="sessname" placeholder="e.g. router-jakarta-1" value="<?= $val('session_name') ?>" required/>
|
||||
<p class="text-xs text-accents-4">Unique ID. Preview: <span id="sessname-preview" class="font-mono text-primary font-bold">...</span></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="quick_access" name="quick_access" class="checkbox" <?= (isset($router['quick_access']) && $router['quick_access'] == 1) ? 'checked' : '' ?> value="1">
|
||||
<label for="quick_access" class="text-sm font-medium cursor-pointer select-none">Show in Quick Access (Home Page)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-accents-2 pt-6">
|
||||
<h2 class="text-base font-semibold mb-4">Connection Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">IP Address</label>
|
||||
<input class="form-control w-full" type="text" name="ipmik" placeholder="192.168.88.1" value="<?= $val('ip_address') ?>" required/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Username</label>
|
||||
<input class="form-control w-full" type="text" name="usermik" placeholder="admin" value="<?= $val('username') ?>" required/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Password</label>
|
||||
<input class="form-control w-full" type="password" name="passmik" <?= isset($router) ? '' : 'required' ?> />
|
||||
<?php if(isset($router)): ?>
|
||||
<p class="text-xs text-accents-4">Leave empty to keep existing password.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-accents-2 pt-6">
|
||||
<h2 class="text-base font-semibold mb-4">Hotspot Information</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Hotspot Name</label>
|
||||
<input class="form-control w-full" type="text" name="hotspotname" placeholder="My Hotspot ID" value="<?= $val('hotspot_name') ?>" required/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">DNS Name</label>
|
||||
<input class="form-control w-full" type="text" name="dnsname" placeholder="hotspot.net" value="<?= $val('dns_name') ?>" required/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Traffic Interface</label>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="flex-grow">
|
||||
<select class="custom-select w-full" name="iface" id="iface" data-search="true" required>
|
||||
<option value="<?= $val('interface') ?: 'ether1' ?>"><?= $val('interface') ?: 'ether1' ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" id="check-interface-btn" class="btn btn-secondary whitespace-nowrap" title="Check connection and fetch interfaces">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Currency</label>
|
||||
<input class="form-control w-full" type="text" name="currency" value="<?= $val('currency') ?: 'Rp' ?>" required/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Auto Reload (Sec)</label>
|
||||
<input class="form-control w-full" type="number" min="10" name="areload" value="<?= $val('reload_interval') ?: 10 ?>" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 flex justify-end gap-3">
|
||||
<a href="/settings/routers" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-secondary" name="action" value="save">
|
||||
Save
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" name="action" value="connect">
|
||||
Save & Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/router-form.js"></script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
110
app/Views/settings/index.php
Normal file
110
app/Views/settings/index.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
$title = "Settings";
|
||||
$no_main_container = true;
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<!-- Sub-Navbar Navigation -->
|
||||
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
|
||||
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Router Sessions</h1>
|
||||
<p class="text-accents-5 mt-2">Manage your stored MikroTik connections.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<div class="hidden md:block">
|
||||
<!-- Spacer or Breadcrumbs if needed -->
|
||||
</div>
|
||||
<a href="/settings/add" class="btn btn-primary w-full md:w-auto">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add Router
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($routers)): ?>
|
||||
<div class="card flex flex-col items-center justify-center py-16 text-center border-dashed">
|
||||
<div class="rounded-full bg-accents-1 p-4 mb-4">
|
||||
<i data-lucide="server-off" class="w-8 h-8 text-accents-4"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium mb-2">No routers configured</h3>
|
||||
<p class="text-accents-5 mb-6 max-w-sm mx-auto">Connect your first MikroTik router to start managing hotspots and vouchers.</p>
|
||||
<a href="/settings/add" class="btn btn-primary">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Connect Router
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-container">
|
||||
<table class="table-glass">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Session Name</th>
|
||||
<th scope="col">Hotspot Name</th>
|
||||
<th scope="col">IP Address</th>
|
||||
<th scope="col" class="relative text-right">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($routers as $router): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<div class="h-8 w-8 rounded bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
|
||||
<?= strtoupper(substr($router['session_name'], 0, 2)) ?>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<?= htmlspecialchars($router['session_name']) ?>
|
||||
<?php if(isset($router['quick_access']) && $router['quick_access'] == 1): ?>
|
||||
<i data-lucide="star" class="w-3 h-3 text-yellow-500 fill-current" title="Quick Access Enabled"></i>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-xs text-accents-5">ID: <?= $router['id'] ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm text-foreground"><?= htmlspecialchars($router['hotspot_name']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm text-accents-5 font-mono"><?= htmlspecialchars($router['ip_address']) ?></div>
|
||||
</td>
|
||||
<td class="text-right text-sm font-medium flex justify-end gap-2">
|
||||
<a href="/<?= htmlspecialchars($router['session_name']) ?>/dashboard" class="btn btn-secondary btn-sm h-8 px-3">
|
||||
Open
|
||||
</a>
|
||||
<a href="/settings/edit/<?= $router['id'] ?>" class="btn btn-secondary btn-sm h-8 px-3" title="Edit">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<form action="/settings/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Disconnect Router?', 'Are you sure you want to disconnect <?= htmlspecialchars($router['session_name']) ?>?', 'Disconnect', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="id" value="<?= $router['id'] ?>">
|
||||
<button type="submit" class="btn hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 border border-transparent h-8 px-2" title="Delete">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="bg-accents-1 px-4 py-3 border-t border-accents-2 flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 sm:px-6">
|
||||
<div class="text-sm text-accents-5">
|
||||
Showing all <?= count($routers) ?> stored sessions
|
||||
</div>
|
||||
<a href="/settings/add" class="btn btn-primary btn-sm w-full sm:w-auto justify-center">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add New
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
121
app/Views/settings/logos.php
Normal file
121
app/Views/settings/logos.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
$title = "Logo Management";
|
||||
$no_main_container = true;
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<!-- Sub-Navbar Navigation -->
|
||||
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.logos_title">Logo Management</h1>
|
||||
<p class="text-accents-5 mt-2" data-i18n="settings.logos_subtitle">Upload and manage logos for your hotspots and vouchers.</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
|
||||
<div class="space-y-8">
|
||||
<!-- Section Header (Removed redundant Logos) -->
|
||||
|
||||
<!-- Upload Section -->
|
||||
<section>
|
||||
<div class="card p-8 border-dashed border-2 bg-accents-1 hover:bg-background transition-colors text-center relative group">
|
||||
<form action="/settings/logos/upload" method="POST" enctype="multipart/form-data" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.svg,.gif" onchange="this.form.submit()" class="form-control-file">
|
||||
</form>
|
||||
|
||||
<div class="flex flex-col items-center justify-center pointer-events-none">
|
||||
<div class="h-12 w-12 rounded-full bg-accents-2 flex items-center justify-center mb-4 group-hover:bg-primary group-hover:text-white transition-colors">
|
||||
<i data-lucide="upload-cloud" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium mb-1" data-i18n="settings.upload_new_logo">Upload New Logo</h3>
|
||||
<p class="text-sm text-accents-5" data-i18n="settings.drag_drop">Drag and drop or click to select file</p>
|
||||
<p class="text-xs text-accents-4 mt-2" data-i18n="settings.supports_formats">Supports PNG, JPG, SVG, GIF</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Gallery Section -->
|
||||
<section>
|
||||
<?php if (empty($logos)): ?>
|
||||
<div class="text-center py-12">
|
||||
<p class="text-accents-5" data-i18n="settings.no_logos">No logos uploaded yet.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||
<?php foreach ($logos as $logo): ?>
|
||||
<div class="group relative card !p-0 overflow-hidden border border-accents-2 bg-background hover:shadow-md transition-all">
|
||||
<!-- Image Preview -->
|
||||
<div class="aspect-square flex items-center justify-center p-4 bg-accents-1 relative" style="background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;">
|
||||
<img src="<?= $logo['path'] ?>" alt="<?= htmlspecialchars($logo['name']) ?>" class="max-w-full max-h-full object-contain">
|
||||
|
||||
<!-- Overlay Actions -->
|
||||
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
|
||||
<span class="text-white font-mono text-lg font-bold bg-black/50 px-2 py-1 rounded"><?= $logo['id'] ?></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="copyToClipboard('<?= $logo['id'] ?>')" class="p-2 bg-white text-black rounded hover:bg-accents-2 transition-colors" title="Copy ID">
|
||||
<i data-lucide="hash" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<form action="/settings/logos/delete" method="POST" class="delete-logo-form">
|
||||
<input type="hidden" name="id" value="<?= $logo['id'] ?>">
|
||||
<button type="submit" class="p-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors" title="Delete">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-3 border-t border-accents-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<code class="text-xs font-bold bg-accents-2 px-1 rounded"><?= $logo['id'] ?></code>
|
||||
<span class="text-xs text-accents-5 uppercase"><?= $logo['type'] ?></span>
|
||||
</div>
|
||||
<p class="text-xs text-accents-5 mt-1 truncate" title="<?= htmlspecialchars($logo['name']) ?>"><?= htmlspecialchars($logo['name']) ?></p>
|
||||
<div class="flex items-center justify-between mt-1 text-xs text-accents-4">
|
||||
<span><?= $logo['formatted_size'] ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div> <!-- End Space-y-8 -->
|
||||
</div> <!-- End Content Area -->
|
||||
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const title = window.i18n ? window.i18n.t('settings.id_copied') : 'ID Copied';
|
||||
const desc = window.i18n ? window.i18n.t('settings.logo_id_copied_desc', {id: text}) : `Logo ID <strong>${text}</strong> copied to clipboard.`;
|
||||
Mivo.alert('success', title, desc);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Intercept Logo Deletion
|
||||
const deleteForms = document.querySelectorAll('.delete-logo-form');
|
||||
deleteForms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const logoId = this.querySelector('input[name="id"]').value;
|
||||
Mivo.confirm(
|
||||
window.i18n ? window.i18n.t('settings.delete_logo_title') : 'Delete Logo?',
|
||||
window.i18n ? window.i18n.t('settings.delete_logo_confirm', {id: logoId}) : `Are you sure you want to delete logo <strong>${logoId}</strong>?`,
|
||||
window.i18n ? window.i18n.t('common.delete') : 'Yes, Delete',
|
||||
window.i18n ? window.i18n.t('common.cancel') : 'Cancel'
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
122
app/Views/settings/systems.php
Normal file
122
app/Views/settings/systems.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
$title = "Settings";
|
||||
$no_main_container = true;
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<!-- Sub-Navbar Navigation -->
|
||||
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.system">General Settings</h1>
|
||||
<p class="text-accents-5 mt-2" data-i18n="settings.system_desc">System-wide configurations and security.</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
|
||||
<div class="space-y-8">
|
||||
|
||||
<!-- Section Header (Removed redundant General) -->
|
||||
<div class="pb-5">
|
||||
<h3 class="text-lg font-medium leading-6 text-foreground" data-i18n="settings.security">Security & Access</h3>
|
||||
</div>
|
||||
|
||||
<!-- Admin Password -->
|
||||
<div class="card">
|
||||
<form action="/settings/admin/update" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 w-full">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-foreground" data-i18n="settings.admin_username">Admin Username</label>
|
||||
<div class="relative">
|
||||
<input type="text" class="form-control w-full bg-accents-1 text-accents-5 cursor-not-allowed pl-10" value="<?= htmlspecialchars($username) ?>" readonly disabled>
|
||||
<i data-lucide="lock" class="absolute left-3 top-2.5 h-4 w-4 text-accents-4"></i>
|
||||
</div>
|
||||
<p class="text-xs text-accents-4" data-i18n="settings.admin_username_desc">
|
||||
<i class="inline-block w-3 h-3 mr-1 align-middle" data-lucide="info"></i>
|
||||
For security reasons, the administrator username cannot be changed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-foreground" data-i18n="settings.change_password">Change Password</label>
|
||||
<div class="relative">
|
||||
<input type="password" name="admin_password" class="form-control w-full pl-10" placeholder="Enter new password" data-i18n-placeholder="settings.new_password_placeholder">
|
||||
<i data-lucide="key" class="absolute left-3 top-2.5 h-4 w-4 text-accents-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4 border-t border-accents-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span data-i18n="settings.update_password">Update Password</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Global Configuration -->
|
||||
<div class="card">
|
||||
<form action="/settings/global/update" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 w-full">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-foreground" data-i18n="settings.quick_print_mode">Quick Print Mode</label>
|
||||
<div class="relative">
|
||||
<select name="quick_print_mode" class="custom-select w-full">
|
||||
<option value="0" <?= ($settings['quick_print_mode'] ?? '0') == '0' ? 'selected' : '' ?> data-i18n="common.forms.disabled">Disabled</option>
|
||||
<option value="1" <?= ($settings['quick_print_mode'] ?? '0') == '1' ? 'selected' : '' ?> data-i18n="common.forms.enabled">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-accents-4" data-i18n="settings.quick_print_mode_desc">Enable direct printing for voucher generation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-accents-2 mt-6">
|
||||
<button type="submit" class="btn btn-primary" data-i18n="settings.save_global">
|
||||
Save Global Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Data Management -->
|
||||
<div class="card">
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-medium" data-i18n="settings.data_management">Data Management</h4>
|
||||
<p class="text-sm text-accents-5" data-i18n="settings.data_management_desc">Backup or restore your application data.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Backup -->
|
||||
<div class="p-4 rounded-lg bg-accents-1 border border-accents-2 flex flex-col h-full">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium mb-2 text-sm" data-i18n="settings.backup_data">Backup Data</h4>
|
||||
<p class="text-xs text-accents-5 mb-4" data-i18n="settings.backup_data_desc">Download a configuration file (.mivo) containing your database and settings.</p>
|
||||
</div>
|
||||
<a href="/settings/backup" class="btn btn-primary w-full justify-center text-sm mt-auto">
|
||||
<i data-lucide="download" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.download_backup">Download Backup</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Restore -->
|
||||
<div class="p-4 rounded-lg bg-accents-1 border border-accents-2 flex flex-col h-full">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium mb-2 text-sm" data-i18n="settings.restore_data">Restore Data</h4>
|
||||
<p class="text-xs text-accents-5 mb-4" data-i18n="settings.restore_data_desc">Upload a previously backup file (.mivo). <strong>Overwrites or adds to existing data.</strong></p>
|
||||
</div>
|
||||
<form action="/settings/restore" method="POST" enctype="multipart/form-data" class="flex flex-col sm:flex-row gap-2 mt-auto">
|
||||
<div class="w-full">
|
||||
<input type="file" name="backup_file" accept=".mivo" class="form-control-file" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto mt-2 sm:mt-0" onclick="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.restore_data') : 'Restore Data?', window.i18n ? window.i18n.t('settings.warning_restore') : 'WARNING: This will restore settings from the file and may overwrite existing data. Continue?', window.i18n ? window.i18n.t('settings.restore') : 'Restore', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.closest('form').submit(); });">
|
||||
<span data-i18n="settings.restore">Restore</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
3
app/Views/settings/templates/add.php
Normal file
3
app/Views/settings/templates/add.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
// Just include the edit view, logic is handled there
|
||||
include __DIR__ . '/edit.php';
|
||||
488
app/Views/settings/templates/edit.php
Normal file
488
app/Views/settings/templates/edit.php
Normal file
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
// Template Editor (Shared for Add/Edit)
|
||||
$isEdit = isset($template);
|
||||
$title = $isEdit ? 'Edit Template' : 'New Template';
|
||||
$initialContent = $template['content'] ?? '<div style="border: 1px solid #000; padding: 10px; width: 300px; background-color: #fff;">
|
||||
<h3>{{dns_name}}</h3>
|
||||
<p>User: {{username}}</p>
|
||||
<p>Pass: {{password}}</p>
|
||||
<p>Price: {{price}}</p>
|
||||
<p>Valid: {{validity}}</p>
|
||||
</div>';
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<div class="flex flex-col lg:h-[calc(100vh-8rem)] gap-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 flex-shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/settings/templates" class="text-accents-5 hover:text-foreground transition-colors">
|
||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||
</a>
|
||||
<h1 class="text-xl font-bold tracking-tight text-foreground"><?= $title ?></h1>
|
||||
</div>
|
||||
|
||||
<form id="templateForm" action="<?= $isEdit ? '/settings/templates/update' : '/settings/templates/store' ?>" method="POST" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 w-full lg:w-auto">
|
||||
<?php if ($isEdit): ?>
|
||||
<input type="hidden" name="id" value="<?= $template['id'] ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="text" name="name" value="<?= htmlspecialchars($template['name'] ?? 'New Template') ?>" required class="form-input w-full lg:w-64" placeholder="Template Name" data-i18n-placeholder="settings.template_name">
|
||||
|
||||
<button type="submit" class="btn btn-primary h-9 justify-center">
|
||||
<i data-lucide="save" class="w-4 h-4 mr-2"></i> <span data-i18n="common.save">Save</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Editor Layout -->
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-6 overflow-hidden min-h-0">
|
||||
|
||||
<!-- Left: Code Editor -->
|
||||
<div class="flex-1 flex flex-col bg-background border border-accents-2 rounded-lg overflow-hidden min-w-0 min-h-0 h-[400px] sm:h-[500px] lg:h-auto shrink-0">
|
||||
<div class="bg-accents-1 px-4 py-3 border-b border-accents-2 flex items-center justify-between gap-4">
|
||||
<span class="text-xs font-mono font-medium text-accents-5 whitespace-nowrap" data-i18n="settings.html_source">HTML Source</span>
|
||||
|
||||
<!-- Scrollable Toolbar -->
|
||||
<div class="flex-1 flex gap-2 overflow-x-auto no-scrollbar mask-fade-right py-1 px-1">
|
||||
<div class="flex gap-2 whitespace-nowrap">
|
||||
<!-- Help Button -->
|
||||
<button type="button" onclick="toggleDocs()" class="text-xs px-2 py-1 bg-accents-2 hover:bg-accents-3 text-accents-8 rounded transition-colors flex items-center gap-1">
|
||||
<i data-lucide="help-circle" class="w-3 h-3"></i> <span data-i18n="settings.docs">Docs</span>
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="insertVar('{{username}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{username}}</button>
|
||||
<button type="button" onclick="insertVar('{{password}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{password}}</button>
|
||||
<button type="button" onclick="insertVar('{{price}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{price}}</button>
|
||||
<button type="button" onclick="insertVar('{{validity}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{validity}}</button>
|
||||
<button type="button" onclick="insertVar('{{timelimit}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{timelimit}}</button>
|
||||
<button type="button" onclick="insertVar('{{datalimit}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{datalimit}}</button>
|
||||
<button type="button" onclick="insertVar('{{profile}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{profile}}</button>
|
||||
<button type="button" onclick="insertVar('{{dns_name}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{dns_name}}</button>
|
||||
<button type="button" onclick="insertVar('{{login_url}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{login_url}}</button>
|
||||
<button type="button" onclick="insertVar('{{qrcode}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors" title="Insert QR Code">{{qrcode}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="codeEditor" name="content" form="templateForm" class="form-control flex-1 w-full font-mono text-sm resize-none h-[500px]" spellcheck="false"><?= htmlspecialchars($initialContent) ?></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview -->
|
||||
<div class="flex-1 flex flex-col border border-accents-2 rounded-lg bg-accents-1 relative overflow-hidden min-h-[500px] shrink-0 lg:h-auto lg:min-h-0">
|
||||
<div class="bg-background px-4 py-2 border-b border-accents-2 flex items-center justify-between">
|
||||
<span class="text-xs font-mono font-medium text-accents-5" data-i18n="settings.live_preview">Live Preview</span>
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4 text-accents-5 cursor-pointer hover:text-foreground" onclick="updatePreview()"></i>
|
||||
</div>
|
||||
<!-- Scaled Preview Container - White Paper Simulation -->
|
||||
<div class="flex-1 overflow-auto flex items-center justify-center p-8 bg-zinc-900/50">
|
||||
<div id="previewContainer" class="bg-white text-black shadow-xl p-4 min-w-[300px] min-h-[300px] flex items-center justify-center rounded-sm">
|
||||
<!-- Content Injected Here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrious.min.js"></script>
|
||||
</div>
|
||||
|
||||
<!-- Documentation Modal -->
|
||||
<div id="docsModal" class="fixed inset-0 z-50 hidden transition-all duration-200">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm opacity-0 transition-opacity duration-200" onclick="toggleDocs()"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="absolute inset-x-0 top-[10%] mx-auto max-w-2xl bg-background border border-accents-2 shadow-2xl rounded-xl overflow-hidden flex flex-col max-h-[80vh] opacity-0 scale-95 transition-all duration-200 origin-top">
|
||||
<div class="px-6 py-4 border-b border-accents-2 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold" data-i18n="settings.template_variables">Template Variables</h2>
|
||||
<button onclick="toggleDocs()" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto custom-scrollbar">
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<p class="text-sm text-accents-5 mb-4" data-i18n="settings.variables_desc">Use these variables in your HTML source. They will be replaced with actual user data during printing.</p>
|
||||
|
||||
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2">Basic Variables</h3>
|
||||
<div class="grid grid-cols-1 gap-2 mb-6">
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{username}}</code>
|
||||
<span class="text-sm text-accents-6">Username</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{password}}</code>
|
||||
<span class="text-sm text-accents-6">Password</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{price}}</code>
|
||||
<span class="text-sm text-accents-6">Price (formatted)</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{validity}}</code>
|
||||
<span class="text-sm text-accents-6">Validity (Raw)</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{timelimit}}</code>
|
||||
<span class="text-sm text-accents-6">Time Limit (Formatted)</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{datalimit}}</code>
|
||||
<span class="text-sm text-accents-6">Data Limit (Formatted)</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{profile}}</code>
|
||||
<span class="text-sm text-accents-6">User Profile Name</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{dns_name}}</code>
|
||||
<span class="text-sm text-accents-6">DNS Name / Hotspot Name</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||
<code class="text-sm font-mono text-primary">{{login_url}}</code>
|
||||
<span class="text-sm text-accents-6">Login URL (http://dnsname/login)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2" data-i18n="settings.qr_code">QR Code</h3>
|
||||
<div class="p-4 rounded bg-accents-1 border border-accents-2">
|
||||
<p class="mb-2"><code class="text-sm font-mono text-primary">{{qrcode}}</code></p>
|
||||
<p class="text-sm text-accents-6 mb-4" data-i18n="settings.qr_desc">Generates a QR Code containing the Login URL with username and password.</p>
|
||||
|
||||
<h4 class="text-xs font-bold uppercase text-accents-5 mb-2" data-i18n="settings.custom_attributes">Custom Attributes</h4>
|
||||
<ul class="text-sm space-y-2 list-disc list-inside text-accents-6 mb-4">
|
||||
<li><strong class="text-foreground">fg</strong>: Foreground color (name or hex)</li>
|
||||
<li><strong class="text-foreground">bg</strong>: Background color (name or hex)</li>
|
||||
<li><strong class="text-foreground">size</strong>: Size in pixels (default 100)</li>
|
||||
<li><strong class="text-foreground">padding</strong>: Padding around QR code (pixels)</li>
|
||||
<li><strong class="text-foreground">rounded</strong>: Corner radius (pixels)</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="text-xs font-bold uppercase text-accents-5 mb-1" data-i18n="settings.examples">Examples:</h4>
|
||||
<div class="bg-background p-2 rounded border border-accents-2 space-y-1 font-mono text-xs">
|
||||
<p>{{qrcode fg=red bg=yellow}}</p>
|
||||
<p>{{qrcode size=200 padding=10 rounded=15}}</p>
|
||||
<p>{{qrcode fg=#000 bg=#fff}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-accents-2 bg-accents-1 flex justify-end">
|
||||
<button onclick="toggleDocs()" class="btn btn-secondary" data-i18n="common.cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Documentation Modal Animation ---
|
||||
function toggleDocs() {
|
||||
const modal = document.getElementById('docsModal');
|
||||
const content = modal.querySelector('div.bg-background'); // The modal card
|
||||
|
||||
if (modal.classList.contains('hidden')) {
|
||||
// Open
|
||||
modal.classList.remove('hidden');
|
||||
// Small delay to allow display:block to apply before opacity transition
|
||||
setTimeout(() => {
|
||||
modal.firstElementChild.classList.remove('opacity-0'); // Backdrop
|
||||
content.classList.remove('opacity-0', 'scale-95');
|
||||
content.classList.add('opacity-100', 'scale-100');
|
||||
}, 10);
|
||||
} else {
|
||||
// Close
|
||||
modal.firstElementChild.classList.add('opacity-0');
|
||||
content.classList.remove('opacity-100', 'scale-100');
|
||||
content.classList.add('opacity-0', 'scale-95');
|
||||
|
||||
setTimeout(() => {
|
||||
modal.classList.add('hidden');
|
||||
}, 200); // Match duration
|
||||
}
|
||||
}
|
||||
|
||||
// --- Editor Logic ---
|
||||
const editor = document.getElementById('codeEditor');
|
||||
const preview = document.getElementById('previewContainer');
|
||||
|
||||
// History Stack for Undo/Redo
|
||||
let historyStack = [];
|
||||
let redoStack = [];
|
||||
let isTyping = false;
|
||||
let typingTimer = null;
|
||||
|
||||
// Initial State
|
||||
historyStack.push({ value: editor.value, selectionStart: 0, selectionEnd: 0 });
|
||||
|
||||
function saveState() {
|
||||
// Limit stack size
|
||||
if (historyStack.length > 50) historyStack.shift();
|
||||
|
||||
const lastState = historyStack[historyStack.length - 1];
|
||||
if (lastState && lastState.value === editor.value) return; // No change
|
||||
|
||||
historyStack.push({
|
||||
value: editor.value,
|
||||
selectionStart: editor.selectionStart,
|
||||
selectionEnd: editor.selectionEnd
|
||||
});
|
||||
redoStack = []; // Clear redo on new change
|
||||
}
|
||||
|
||||
// Debounced save for typing
|
||||
editor.addEventListener('input', (e) => {
|
||||
if (!isTyping) {
|
||||
// Save state *before* a burst of typing starts?
|
||||
// Actually usually we save *after*.
|
||||
// For robust undo: save state Before modification if possible, or assume previous state is safe.
|
||||
// Simplified: Save debounced.
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(saveState, 500);
|
||||
}
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// --- Keyboard Shortcuts (Undo/Redo, Tab, Enter) ---
|
||||
editor.addEventListener('keydown', function(e) {
|
||||
// Undo: Ctrl+Z
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
// Redo: Ctrl+Y or Ctrl+Shift+Z
|
||||
if (((e.ctrlKey || e.metaKey) && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: Insert/Remove Indent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
const val = this.value;
|
||||
const tabChar = " "; // 4 spaces
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Un-indent (naive single line)
|
||||
// TODO: Multiline support if needed. For now simple cursor unindent.
|
||||
// Checking previous chars
|
||||
// Not implemented for simplicity, just preventing focus loss.
|
||||
} else {
|
||||
// Insert Tab
|
||||
// Use setRangeText to preserve browser undo buffer if mixed usage?
|
||||
// But we have custom undo.
|
||||
this.value = val.substring(0, start) + tabChar + val.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + tabChar.length;
|
||||
saveState();
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
// Enter: Auto-indent checking previous line
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
const val = this.value;
|
||||
|
||||
// Find start of current line
|
||||
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
||||
const currentLine = val.substring(lineStart, start);
|
||||
|
||||
// Calculate indentation
|
||||
const match = currentLine.match(/^\s*/);
|
||||
const indent = match ? match[0] : '';
|
||||
|
||||
const insert = '\n' + indent;
|
||||
|
||||
this.value = val.substring(0, start) + insert + val.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + insert.length;
|
||||
|
||||
saveState(); // Immediate save on Enter
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
function undo() {
|
||||
if (historyStack.length > 1) { // Keep initial state
|
||||
const current = historyStack.pop();
|
||||
redoStack.push(current);
|
||||
|
||||
const prev = historyStack[historyStack.length - 1];
|
||||
editor.value = prev.value;
|
||||
editor.selectionStart = prev.selectionStart;
|
||||
editor.selectionEnd = prev.selectionEnd;
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (redoStack.length > 0) {
|
||||
const next = redoStack.pop();
|
||||
historyStack.push(next);
|
||||
|
||||
editor.value = next.value;
|
||||
editor.selectionStart = next.selectionStart;
|
||||
editor.selectionEnd = next.selectionEnd;
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
function insertVar(text) {
|
||||
saveState(); // Save state before insertion
|
||||
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const val = editor.value;
|
||||
editor.value = val.substring(0, start) + text + val.substring(end);
|
||||
editor.selectionStart = editor.selectionEnd = start + text.length;
|
||||
editor.focus();
|
||||
|
||||
saveState(); // Save state after insertion
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Live Preview Logic
|
||||
|
||||
// Inject Logo Map from PHP
|
||||
const logoMap = <?= json_encode($logoMap ?? []) ?>;
|
||||
|
||||
// Sample Data for Preview
|
||||
const sampleData = {
|
||||
'{{username}}': 'user123',
|
||||
'{{password}}': 'pass789',
|
||||
'{{price}}': 'Rp 5.000',
|
||||
'{{validity}}': ' 3 Hours',
|
||||
'{{timelimit}}': ' 3 Hours',
|
||||
'{{datalimit}}': '500 MB',
|
||||
'{{profile}}': 'General',
|
||||
'{{comment}}': 'mikhmon',
|
||||
'{{hotspotname}}': 'Mikhmon Hotspot',
|
||||
'{{num}}': '1',
|
||||
'{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder
|
||||
'{{dns_name}}': 'hotspot.mikhmon',
|
||||
'{{login_url}}': 'http://hotspot.mikhmon/login',
|
||||
};
|
||||
|
||||
function updatePreview() {
|
||||
let content = editor.value;
|
||||
|
||||
// 1. Handle {{logo id=...}}
|
||||
content = content.replace(/\{\{logo\s+id=['"]?([^'"\s]+)['"]?\}\}/gi, (match, id) => {
|
||||
if (logoMap[id]) {
|
||||
return `<img src="${logoMap[id]}" style="height:50px; width:auto;">`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// 2. Simple Replace for other variables
|
||||
for (const [key, value] of Object.entries(sampleData)) {
|
||||
content = content.replaceAll(key, value);
|
||||
}
|
||||
|
||||
// 3. Handle QR Code - Local Generation with Attributes
|
||||
content = content.replace(/\{\{qrcode(?:\s+(.*?))?\}\}/gi, (match, attrs) => {
|
||||
const qrValue = sampleData['{{login_url}}'] + '?user=' + sampleData['{{username}}'] + '&password=' + sampleData['{{password}}'];
|
||||
|
||||
let opts = {
|
||||
value: qrValue,
|
||||
size: 100,
|
||||
foreground: 'black',
|
||||
};
|
||||
|
||||
let roundedStyle = '';
|
||||
|
||||
// Default styling options
|
||||
let styleOpts = {
|
||||
padding: 0,
|
||||
background: 'white',
|
||||
logo: null
|
||||
};
|
||||
|
||||
opts.backgroundAlpha = 0;
|
||||
|
||||
if (attrs) {
|
||||
const fgMatch = attrs.match(/fg\s*=\s*['"]?([^'"\s]+)['"]?/i);
|
||||
if (fgMatch) opts.foreground = fgMatch[1];
|
||||
|
||||
const bgMatch = attrs.match(/bg\s*=\s*['"]?([^'"\s]+)['"]?/i);
|
||||
if (bgMatch) styleOpts.background = bgMatch[1];
|
||||
|
||||
const sizeMatch = attrs.match(/size\s*=\s*['"]?(\d+)['"]?/i);
|
||||
if (sizeMatch) opts.size = parseInt(sizeMatch[1]);
|
||||
|
||||
const paddingMatch = attrs.match(/padding\s*=\s*['"]?(\d+)['"]?/i);
|
||||
if (paddingMatch) styleOpts.padding = parseInt(paddingMatch[1]);
|
||||
|
||||
const roundedMatch = attrs.match(/rounded\s*=\s*['"]?(\d+)['"]?/i);
|
||||
if (roundedMatch) roundedStyle = `border-radius: ${roundedMatch[1]}px;`;
|
||||
|
||||
const logoMatch = attrs.match(/logo\s*=\s*['"]?([^'"\s]+)['"]?/i);
|
||||
if (logoMatch) styleOpts.logo = logoMatch[1];
|
||||
}
|
||||
|
||||
const qr = new QRious(opts);
|
||||
const qrDataUrl = qr.toDataURL();
|
||||
|
||||
// Construct compound style
|
||||
const cssBg = `background-color: ${styleOpts.background};`;
|
||||
const cssPadding = styleOpts.padding ? `padding: ${styleOpts.padding}px;` : '';
|
||||
const baseStyle = `display: inline-block; vertical-align: middle; ${cssBg} ${cssPadding} ${roundedStyle}`;
|
||||
|
||||
// If Logo requested, we need Canvas manipulation.
|
||||
if (styleOpts.logo && logoMap[styleOpts.logo]) {
|
||||
// Create a canvas (not added to DOM) to draw composite
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const size = opts.size;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
// Since QRious gives dataURL, we need to load it back
|
||||
// But wait, this is synchronous preview. Loading image is async.
|
||||
// We can return a placeholder or handle async?
|
||||
// Simple hack: Return an IMG with a unique class, script loads it?
|
||||
// Or better: Just render the QR + Logo overlay using CSS absolute positioning?
|
||||
// Print view uses Canvas. Live Preview uses innerHTML.
|
||||
// CSS Overlay is easiest for preview, but Print View logic uses Canvas-drawing.
|
||||
// Let's stick to Canvas drawing for 1:1 fidelity, BUT we need async handling.
|
||||
// We can use a unique ID + script injection like print view?
|
||||
// Yes, let's replicate print view logic.
|
||||
|
||||
const uniqueId = 'preview-qr-' + Math.random().toString(36).substr(2, 9);
|
||||
const logoPath = logoMap[styleOpts.logo];
|
||||
|
||||
// Generate Script to execute after insertion
|
||||
// We need to delay execution until element exists.
|
||||
// Note: innerHTML scripts don't run automatically in all contexts, but updatePreview sets innerHTML.
|
||||
// Scripts inserted via innerHTML do NOT execute.
|
||||
// We need another way or just CSS overlay for preview.
|
||||
|
||||
// CSS Overlay Approach for Preview (Simpler/Faster)
|
||||
// <div style="position:relative; ...">
|
||||
// <img src="QR">
|
||||
// <img src="LOGO" style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); width:20%;">
|
||||
// </div>
|
||||
|
||||
return `<div style="position:relative; ${baseStyle}">
|
||||
<img src="${qrDataUrl}" style="display:block;">
|
||||
<img src="${logoPath}" style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); width:20%; height:auto;">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return '<img src="' + qrDataUrl + '" alt="QR Code" style="' + baseStyle + '">';
|
||||
});
|
||||
|
||||
preview.innerHTML = content;
|
||||
}
|
||||
|
||||
editor.addEventListener('input', updatePreview); // Handled by debouncer above too, but OK.
|
||||
|
||||
// Init
|
||||
updatePreview();
|
||||
</script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
181
app/Views/settings/templates/index.php
Normal file
181
app/Views/settings/templates/index.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
$title = "Voucher Templates";
|
||||
$no_main_container = true;
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<!-- Sub-Navbar Navigation -->
|
||||
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.templates_title">Voucher Templates</h1>
|
||||
<p class="text-accents-5 mt-2" data-i18n="settings.templates_subtitle">Manage and customize your voucher print designs.</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between border-b border-accents-2 pb-5 gap-4">
|
||||
<div class="hidden md:block">
|
||||
<!-- Spacer -->
|
||||
</div>
|
||||
<a href="/settings/templates/add" class="btn btn-primary w-full sm:w-auto justify-center">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||
<span data-i18n="settings.new_template">New Template</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Template List -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Default Template Card (Read Only) -->
|
||||
<div class="border border-accents-2 rounded-xl overflow-hidden bg-background flex flex-col h-full">
|
||||
<div class="aspect-video bg-accents-1 border-b border-accents-2 w-full h-full relative overflow-hidden flex items-center justify-center group">
|
||||
<!-- Loading Overlay -->
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-accents-1 z-10 transition-opacity duration-500 pointer-events-none input-overlay">
|
||||
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
|
||||
</div>
|
||||
<iframe
|
||||
data-src="/settings/templates/preview/default"
|
||||
src="about:blank"
|
||||
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
||||
scrolling="no"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col flex-grow">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-bold text-foreground" data-i18n="settings.default_template">Default Template</h3>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-accents-2 text-foreground" data-i18n="settings.system_label">System</span>
|
||||
</div>
|
||||
<p class="text-sm text-accents-5 mb-4" data-i18n="settings.default_template_desc">Standard thermal printer friendly template.</p>
|
||||
<button disabled class="w-full py-2 border border-accents-2 rounded text-accents-4 text-sm cursor-not-allowed mt-auto" data-i18n="settings.built_in">
|
||||
Built-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($templates)): ?>
|
||||
<?php foreach ($templates as $tpl): ?>
|
||||
<div class="border border-accents-2 rounded-xl overflow-hidden bg-background hover:shadow-sm transition-shadow flex flex-col h-full">
|
||||
<div class="aspect-video bg-white relative group overflow-hidden">
|
||||
<?php if (!empty($tpl['content'])): ?>
|
||||
<div class="w-full h-full bg-accents-1 relative overflow-hidden flex items-center justify-center group">
|
||||
<!-- Loading Overlay -->
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-accents-1 z-10 transition-opacity duration-500 pointer-events-none input-overlay">
|
||||
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
|
||||
</div>
|
||||
<iframe
|
||||
data-src="/settings/templates/preview/<?= $tpl['id'] ?>"
|
||||
src="about:blank"
|
||||
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
||||
scrolling="no"
|
||||
></iframe>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Placeholder for Preview Thumb -->
|
||||
<div class="absolute inset-0 flex items-center justify-center text-accents-3 bg-accents-1">
|
||||
<i data-lucide="file-code" class="w-8 h-8 opacity-50"></i>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col flex-grow">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-bold text-foreground"><?= htmlspecialchars($tpl['name']) ?></h3>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" data-i18n="settings.custom_label">Custom</span>
|
||||
</div>
|
||||
<p class="text-sm text-accents-5 mb-4 line-clamp-1">Created: <?= htmlspecialchars($tpl['created_at']) ?></p>
|
||||
|
||||
<div class="flex items-center gap-2 mt-auto">
|
||||
<a href="/settings/templates/edit/<?= $tpl['id'] ?>" class="flex-1 btn btn-primary flex justify-center">
|
||||
<i data-lucide="edit-3" class="w-4 h-4 mr-2"></i> <span data-i18n="common.edit">Edit</span>
|
||||
</a>
|
||||
<form action="/settings/templates/delete" method="POST" class="delete-template-form">
|
||||
<input type="hidden" name="id" value="<?= $tpl['id'] ?>">
|
||||
<input type="hidden" name="template_name" value="<?= htmlspecialchars($tpl['name']) ?>">
|
||||
<button type="submit" class="p-2 btn btn-secondary hover:text-red-600 hover:bg-red-50 transition-colors h-9 w-9 flex items-center justify-center">
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div> <!-- End Space-y-6 -->
|
||||
</div> <!-- End Content Area -->
|
||||
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Intercept Template Deletion
|
||||
const deleteForms = document.querySelectorAll('.delete-template-form');
|
||||
deleteForms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const templateName = this.querySelector('input[name="template_name"]').value;
|
||||
Mivo.confirm(
|
||||
window.i18n ? window.i18n.t('settings.delete_template_title') : 'Delete Template?',
|
||||
window.i18n ? window.i18n.t('settings.delete_template_confirm', {name: templateName}) : `Are you sure you want to delete <strong>${templateName}</strong>?`,
|
||||
window.i18n ? window.i18n.t('common.delete') : 'Yes, Delete',
|
||||
window.i18n ? window.i18n.t('common.cancel') : 'Cancel'
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const queue = [];
|
||||
let activeRequests = 0;
|
||||
const CONCURRENCY_LIMIT = 3; // "Threads"
|
||||
|
||||
const processQueue = () => {
|
||||
// Fill up to the limit
|
||||
while (activeRequests < CONCURRENCY_LIMIT && queue.length > 0) {
|
||||
const iframe = queue.shift();
|
||||
activeRequests++;
|
||||
|
||||
// Set src to trigger load
|
||||
iframe.src = iframe.dataset.src;
|
||||
|
||||
// On load (or error), fade in and process next slot
|
||||
const onComplete = () => {
|
||||
iframe.classList.remove('opacity-0');
|
||||
if(iframe.previousElementSibling && iframe.previousElementSibling.classList.contains('input-overlay')) {
|
||||
iframe.previousElementSibling.classList.add('opacity-0');
|
||||
}
|
||||
activeRequests--;
|
||||
setTimeout(processQueue, 50); // Small delay
|
||||
};
|
||||
|
||||
iframe.onload = onComplete;
|
||||
iframe.onerror = onComplete;
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const iframe = entry.target;
|
||||
// Only queue if it hasn't started loading (src is blank) and isn't already queued
|
||||
if (iframe.getAttribute('src') === 'about:blank' && !iframe.classList.contains('queued')) {
|
||||
iframe.classList.add('queued');
|
||||
queue.push(iframe);
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '200px' }); // Preload ahead slightly
|
||||
|
||||
document.querySelectorAll('iframe[data-src]').forEach(iframe => {
|
||||
observer.observe(iframe);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user