mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 13:31:56 +07:00
Chore: Bump version to v1.1.0 and implement automated release system
This commit is contained in:
@@ -10,3 +10,4 @@ docs/
|
|||||||
app/Database/*.sqlite
|
app/Database/*.sqlite
|
||||||
public/assets/img/logos/*
|
public/assets/img/logos/*
|
||||||
!public/assets/img/logos/.gitignore
|
!public/assets/img/logos/.gitignore
|
||||||
|
CNAME
|
||||||
|
|||||||
11
.gitattributes
vendored
11
.gitattributes
vendored
@@ -2,11 +2,20 @@
|
|||||||
/docs export-ignore
|
/docs export-ignore
|
||||||
/.github export-ignore
|
/.github export-ignore
|
||||||
/docker export-ignore
|
/docker export-ignore
|
||||||
|
/CNAME export-ignore
|
||||||
/.gitattributes export-ignore
|
/.gitattributes export-ignore
|
||||||
/.gitignore export-ignore
|
/.gitignore export-ignore
|
||||||
/.dockerignore export-ignore
|
/.dockerignore export-ignore
|
||||||
/.env.example export-ignore
|
/.env.example export-ignore
|
||||||
/deploy_package.tar.gz export-ignore
|
/package.json export-ignore
|
||||||
|
/package-lock.json export-ignore
|
||||||
|
/tailwind.config.js export-ignore
|
||||||
|
/src export-ignore
|
||||||
|
/Dockerfile export-ignore
|
||||||
|
/docker-compose.yml export-ignore
|
||||||
/DOCKER_README.md export-ignore
|
/DOCKER_README.md export-ignore
|
||||||
|
/build_release.ps1 export-ignore
|
||||||
|
/deploy.ps1 export-ignore
|
||||||
|
/serve.bat export-ignore
|
||||||
/phpstan.neon export-ignore
|
/phpstan.neon export-ignore
|
||||||
/phpunit.xml export-ignore
|
/phpunit.xml export-ignore
|
||||||
|
|||||||
53
.github/workflows/release.yml
vendored
Normal file
53
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Create Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.2'
|
||||||
|
extensions: mbstring, xml, ctype, iconv, sqlite3, openssl
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: get_version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Release Directory
|
||||||
|
run: |
|
||||||
|
mkdir release_temp
|
||||||
|
# Export source using git archive (respects .gitattributes)
|
||||||
|
git archive --format=tar HEAD | tar -x -C release_temp
|
||||||
|
|
||||||
|
- name: Install Production Dependencies
|
||||||
|
run: |
|
||||||
|
cd release_temp
|
||||||
|
composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: Build Zip Artifact
|
||||||
|
run: |
|
||||||
|
cd release_temp
|
||||||
|
zip -r ../mivo-v${{ steps.get_version.outputs.VERSION }}.zip .
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: mivo-v${{ steps.get_version.outputs.VERSION }}.zip
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -17,6 +17,7 @@ Thumbs.db
|
|||||||
# Build Artifacts & Deployments
|
# Build Artifacts & Deployments
|
||||||
/deploy_package.tar.gz
|
/deploy_package.tar.gz
|
||||||
/mivo_backup_*.mivo
|
/mivo_backup_*.mivo
|
||||||
|
/mivo-*.zip
|
||||||
|
|
||||||
# Secrets and Environment
|
# Secrets and Environment
|
||||||
.env
|
.env
|
||||||
@@ -27,4 +28,8 @@ docs/.vitepress/cache
|
|||||||
|
|
||||||
# Build Scripts & Artifacts
|
# Build Scripts & Artifacts
|
||||||
build_release.ps1
|
build_release.ps1
|
||||||
*.zip
|
deploy.ps1
|
||||||
|
|
||||||
|
# User Uploads
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitignore
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace App\Config;
|
|||||||
|
|
||||||
class SiteConfig {
|
class SiteConfig {
|
||||||
const APP_NAME = 'MIVO';
|
const APP_NAME = 'MIVO';
|
||||||
const APP_VERSION = 'v1.0';
|
const APP_VERSION = 'v1.1.0';
|
||||||
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
||||||
const CREDIT_NAME = 'DyzulkDev';
|
const CREDIT_NAME = 'DyzulkDev';
|
||||||
const CREDIT_URL = 'https://dyzulk.com';
|
const CREDIT_URL = 'https://dyzulk.com';
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ class ApiController extends Controller {
|
|||||||
$configModel = new Config();
|
$configModel = new Config();
|
||||||
$session = $configModel->getSessionById($id);
|
$session = $configModel->getSessionById($id);
|
||||||
if ($session && !empty($session['password'])) {
|
if ($session && !empty($session['password'])) {
|
||||||
$pass = EncryptionHelper::decrypt($session['password']);
|
// Config::getSessionById already decrypts the password
|
||||||
|
$pass = $session['password'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Core\Middleware;
|
|||||||
class DashboardController extends Controller {
|
class DashboardController extends Controller {
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
Middleware::auth();
|
// Auth handled by Router Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index($session) {
|
public function index($session) {
|
||||||
@@ -101,6 +101,7 @@ class DashboardController extends Controller {
|
|||||||
'hotspot_users' => 'Hotspot Users',
|
'hotspot_users' => 'Hotspot Users',
|
||||||
'hotspot_users' => 'Hotspot Users',
|
'hotspot_users' => 'Hotspot Users',
|
||||||
],
|
],
|
||||||
|
'reload_interval' => $creds['reload'] ?? 5, // Default 5s if not set
|
||||||
'interface' => $creds['interface'] ?? 'ether1'
|
'interface' => $creds['interface'] ?? 'ether1'
|
||||||
];
|
];
|
||||||
// Pass Users Link (Optional: could be part of layout or card link)
|
// Pass Users Link (Optional: could be part of layout or card link)
|
||||||
@@ -108,7 +109,9 @@ class DashboardController extends Controller {
|
|||||||
return $this->view('dashboard', $data);
|
return $this->view('dashboard', $data);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
echo "Connection Failed to " . $creds['ip'];
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ class DhcpController extends Controller
|
|||||||
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
||||||
// Fetch DHCP Leases
|
// Fetch DHCP Leases
|
||||||
$leases = $API->comm("/ip/dhcp-server/lease/print");
|
$leases = $API->comm("/ip/dhcp-server/lease/print");
|
||||||
$API->disconnect();
|
} else {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $config['ip_address']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add index for viewing
|
// Add index for viewing
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ class GeneratorController extends Controller {
|
|||||||
|
|
||||||
$this->view('hotspot/generate', $data);
|
$this->view('hotspot/generate', $data);
|
||||||
} else {
|
} else {
|
||||||
// Handle connection error (flash message ideally, but for now redirect or show error)
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
echo "Connection failed to " . $creds['ip'];
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class HotspotController extends Controller {
|
|||||||
|
|
||||||
$userId = $session; // For view context
|
$userId = $session; // For view context
|
||||||
$users = [];
|
$users = [];
|
||||||
|
$servers = [];
|
||||||
$error = null;
|
$error = null;
|
||||||
|
|
||||||
$API = new RouterOSAPI();
|
$API = new RouterOSAPI();
|
||||||
@@ -40,17 +41,20 @@ class HotspotController extends Controller {
|
|||||||
// Get all hotspot users
|
// Get all hotspot users
|
||||||
$users = $API->comm("/ip/hotspot/user/print");
|
$users = $API->comm("/ip/hotspot/user/print");
|
||||||
|
|
||||||
// Get active users to mark status (optional, can be done later for optimization)
|
// Get servers for dropdown
|
||||||
// $active = $API->comm("/ip/hotspot/active/print");
|
$servers = $API->comm("/ip/hotspot/server/print");
|
||||||
|
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
} else {
|
} else {
|
||||||
$error = "Connection Failed to " . $creds['ip'];
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'users' => $users,
|
'users' => $users,
|
||||||
|
'servers' => $servers,
|
||||||
'error' => $error
|
'error' => $error
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -389,7 +393,9 @@ class HotspotController extends Controller {
|
|||||||
$items = $API->comm("/ip/hotspot/active/print");
|
$items = $API->comm("/ip/hotspot/active/print");
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
} else {
|
} else {
|
||||||
$error = "Connection Failed to " . $creds['ip'];
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@@ -451,7 +457,9 @@ class HotspotController extends Controller {
|
|||||||
$items = $API->comm("/ip/hotspot/host/print");
|
$items = $API->comm("/ip/hotspot/host/print");
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
} else {
|
} else {
|
||||||
$error = "Connection Failed to " . $creds['ip'];
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@@ -484,7 +492,9 @@ class HotspotController extends Controller {
|
|||||||
$items = $API->comm("/ip/hotspot/ip-binding/print");
|
$items = $API->comm("/ip/hotspot/ip-binding/print");
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
} else {
|
} else {
|
||||||
$error = "Connection Failed to " . $creds['ip'];
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@@ -606,7 +616,9 @@ class HotspotController extends Controller {
|
|||||||
$items = $API->comm("/ip/hotspot/walled-garden/ip/print");
|
$items = $API->comm("/ip/hotspot/walled-garden/ip/print");
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
} else {
|
} else {
|
||||||
$error = "Connection Failed to " . $creds['ip'];
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@@ -837,8 +849,9 @@ class HotspotController extends Controller {
|
|||||||
$templateContent = $tpl['content'];
|
$templateContent = $tpl['content'];
|
||||||
$viewName = 'print/custom';
|
$viewName = 'print/custom';
|
||||||
} else {
|
} else {
|
||||||
// Fallback if ID invalid
|
\App\Helpers\FlashHelper::set('error', 'Template Not Found', 'The selected print template could not be found.');
|
||||||
$currentTemplate = 'default';
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/hotspot/users'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ class LogController extends Controller
|
|||||||
$logs = array_reverse($logs);
|
$logs = array_reverse($logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
$API->disconnect();
|
} else {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $config['ip_address']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->view('reports/user_log', [
|
return $this->view('reports/user_log', [
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ class ProfileController extends Controller
|
|||||||
// Use default port 8728 if not specified
|
// Use default port 8728 if not specified
|
||||||
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
|
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
|
||||||
$profiles = $API->comm('/ip/hotspot/user/profile/print');
|
$profiles = $API->comm('/ip/hotspot/user/profile/print');
|
||||||
|
|
||||||
|
// Fetch Pools & Queues for the Modal Form
|
||||||
|
$pools = $API->comm('/ip/pool/print');
|
||||||
|
$simple = $API->comm('/queue/simple/print');
|
||||||
|
$tree = $API->comm('/queue/tree/print');
|
||||||
|
|
||||||
|
$queues = [];
|
||||||
|
foreach ($simple as $q) {
|
||||||
|
if(isset($q['name'])) $queues[] = $q['name'];
|
||||||
|
}
|
||||||
|
foreach ($tree as $q) {
|
||||||
|
if(isset($q['name'])) $queues[] = $q['name'];
|
||||||
|
}
|
||||||
|
sort($queues);
|
||||||
|
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
|
|
||||||
// Process profiles to add metadata from on-login script
|
// Process profiles to add metadata from on-login script
|
||||||
@@ -33,15 +48,14 @@ class ProfileController extends Controller
|
|||||||
$this->view('hotspot/profiles/index', [
|
$this->view('hotspot/profiles/index', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'profiles' => $profiles,
|
'profiles' => $profiles,
|
||||||
|
'pools' => $pools,
|
||||||
|
'queues' => $queues,
|
||||||
'title' => 'User Profiles'
|
'title' => 'User Profiles'
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$this->view('hotspot/profiles/index', [
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
'session' => $session,
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||||
'profiles' => [],
|
exit;
|
||||||
'error' => 'Connection Failed to ' . $creds['ip'],
|
|
||||||
'title' => 'User Profiles'
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,10 @@ class PublicStatusController extends Controller {
|
|||||||
// View: Show Search Page
|
// View: Show Search Page
|
||||||
public function index($session) {
|
public function index($session) {
|
||||||
// Just verify session existence to display Hotspot Name
|
// Just verify session existence to display Hotspot Name
|
||||||
|
// Session verified by RouterCheckMiddleware
|
||||||
$configModel = new Config();
|
$configModel = new Config();
|
||||||
$creds = $configModel->getSession($session);
|
$creds = $configModel->getSession($session);
|
||||||
|
|
||||||
if (!$creds) {
|
|
||||||
// If session invalid, maybe show 404 or generic error
|
|
||||||
echo "Session not found.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'hotspot_name' => $creds['hotspot_name'] ?? 'Hotspot',
|
'hotspot_name' => $creds['hotspot_name'] ?? 'Hotspot',
|
||||||
@@ -92,9 +87,6 @@ class PublicStatusController extends Controller {
|
|||||||
if (!empty($user)) {
|
if (!empty($user)) {
|
||||||
$u = $user[0];
|
$u = $user[0];
|
||||||
|
|
||||||
// DEBUG: Log the user data to see raw values
|
|
||||||
error_log("Status Debug: " . json_encode($u));
|
|
||||||
|
|
||||||
// --- SECURITY CHECK: Hide Unused Vouchers ---
|
// --- SECURITY CHECK: Hide Unused Vouchers ---
|
||||||
$uptimeRaw = $u['uptime'] ?? '0s';
|
$uptimeRaw = $u['uptime'] ?? '0s';
|
||||||
$bytesIn = intval($u['bytes-in'] ?? 0);
|
$bytesIn = intval($u['bytes-in'] ?? 0);
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ class QuickPrintController extends Controller {
|
|||||||
// Dashboard: List Cards
|
// Dashboard: List Cards
|
||||||
public function index($session) {
|
public function index($session) {
|
||||||
$qpModel = new QuickPrintModel();
|
$qpModel = new QuickPrintModel();
|
||||||
$packages = $qpModel->getAllBySession($session);
|
|
||||||
|
$configModel = new Config();
|
||||||
|
$creds = $configModel->getSession($session);
|
||||||
|
$routerId = $creds['id'] ?? null;
|
||||||
|
|
||||||
|
// If no ID (Legacy), fallback to empty list or handle gracefully.
|
||||||
|
// For now, we assume ID exists as per migration plan.
|
||||||
|
$packages = $routerId ? $qpModel->getAllByRouterId($routerId) : [];
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
@@ -32,11 +39,12 @@ class QuickPrintController extends Controller {
|
|||||||
// List/Manage Packages (CRUD)
|
// List/Manage Packages (CRUD)
|
||||||
public function manage($session) {
|
public function manage($session) {
|
||||||
$qpModel = new QuickPrintModel();
|
$qpModel = new QuickPrintModel();
|
||||||
$packages = $qpModel->getAllBySession($session);
|
|
||||||
|
|
||||||
// Need profiles for the Add/Edit Modal
|
|
||||||
$configModel = new Config();
|
$configModel = new Config();
|
||||||
$creds = $configModel->getSession($session);
|
$creds = $configModel->getSession($session);
|
||||||
|
$routerId = $creds['id'] ?? null;
|
||||||
|
|
||||||
|
$packages = $routerId ? $qpModel->getAllByRouterId($routerId) : [];
|
||||||
$profiles = [];
|
$profiles = [];
|
||||||
if ($creds) {
|
if ($creds) {
|
||||||
$API = new RouterOSAPI();
|
$API = new RouterOSAPI();
|
||||||
@@ -63,7 +71,13 @@ class QuickPrintController extends Controller {
|
|||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
|
|
||||||
$session = $_POST['session'] ?? '';
|
$session = $_POST['session'] ?? '';
|
||||||
|
|
||||||
|
$configModel = new Config();
|
||||||
|
$creds = $configModel->getSession($session);
|
||||||
|
$routerId = $creds['id'] ?? 0;
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
|
'router_id' => $routerId,
|
||||||
'session_name' => $session,
|
'session_name' => $session,
|
||||||
'name' => $_POST['name'] ?? 'Package',
|
'name' => $_POST['name'] ?? 'Package',
|
||||||
'server' => $_POST['server'] ?? 'all',
|
'server' => $_POST['server'] ?? 'all',
|
||||||
@@ -71,6 +85,7 @@ class QuickPrintController extends Controller {
|
|||||||
'prefix' => $_POST['prefix'] ?? '',
|
'prefix' => $_POST['prefix'] ?? '',
|
||||||
'char_length' => $_POST['char_length'] ?? 4,
|
'char_length' => $_POST['char_length'] ?? 4,
|
||||||
'price' => $_POST['price'] ?? 0,
|
'price' => $_POST['price'] ?? 0,
|
||||||
|
'selling_price' => $_POST['selling_price'] ?? ($_POST['price'] ?? 0),
|
||||||
'time_limit' => $_POST['time_limit'] ?? '',
|
'time_limit' => $_POST['time_limit'] ?? '',
|
||||||
'data_limit' => $_POST['data_limit'] ?? '',
|
'data_limit' => $_POST['data_limit'] ?? '',
|
||||||
'comment' => $_POST['comment'] ?? '',
|
'comment' => $_POST['comment'] ?? '',
|
||||||
@@ -85,6 +100,40 @@ class QuickPrintController extends Controller {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRUD: Update
|
||||||
|
public function update() {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
|
|
||||||
|
$session = $_POST['session'] ?? '';
|
||||||
|
$id = $_POST['id'] ?? '';
|
||||||
|
|
||||||
|
if (empty($id)) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'common.error', 'toasts.error_missing_id', [], true);
|
||||||
|
header("Location: /" . $session . "/quick-print/manage");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $_POST['name'] ?? 'Package',
|
||||||
|
'profile' => $_POST['profile'] ?? 'default',
|
||||||
|
'prefix' => $_POST['prefix'] ?? '',
|
||||||
|
'char_length' => $_POST['char_length'] ?? 4,
|
||||||
|
'price' => $_POST['price'] ?? 0,
|
||||||
|
'selling_price' => $_POST['selling_price'] ?? ($_POST['price'] ?? 0),
|
||||||
|
'time_limit' => $_POST['time_limit'] ?? '',
|
||||||
|
'data_limit' => $_POST['data_limit'] ?? '',
|
||||||
|
'comment' => $_POST['comment'] ?? '',
|
||||||
|
'color' => $_POST['color'] ?? 'bg-blue-500'
|
||||||
|
];
|
||||||
|
|
||||||
|
$qpModel = new QuickPrintModel();
|
||||||
|
$qpModel->update($id, $data); // Assuming update method exists in simple JSON model
|
||||||
|
|
||||||
|
\App\Helpers\FlashHelper::set('success', 'toasts.package_updated', 'toasts.package_updated_desc', [], true);
|
||||||
|
header("Location: /" . $session . "/quick-print/manage");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// CRUD: Delete
|
// CRUD: Delete
|
||||||
public function delete() {
|
public function delete() {
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
@@ -158,7 +207,9 @@ class QuickPrintController extends Controller {
|
|||||||
$API->comm("/ip/hotspot/user/add", $userData);
|
$API->comm("/ip/hotspot/user/add", $userData);
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
} else {
|
} else {
|
||||||
die("Connection failed");
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/quick-print/manage'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Helpers\FormatHelper;
|
|||||||
class SettingsController extends Controller {
|
class SettingsController extends Controller {
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
Middleware::auth();
|
// Auth handled by Router Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
public function system() {
|
public function system() {
|
||||||
@@ -33,10 +33,6 @@ class SettingsController extends Controller {
|
|||||||
return $this->view('settings/index', ['routers' => $routers]);
|
return $this->view('settings/index', ['routers' => $routers]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add() {
|
|
||||||
return $this->view('settings/form');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... (Existing Store methods) ...
|
// ... (Existing Store methods) ...
|
||||||
public function store() {
|
public function store() {
|
||||||
// Sanitize Session Name (Duplicate Frontend Logic)
|
// Sanitize Session Name (Duplicate Frontend Logic)
|
||||||
@@ -102,33 +98,7 @@ class SettingsController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function edit() {
|
|
||||||
// ID passed via query param or route param?
|
|
||||||
// Our router supports {id} but let's check how we handle it.
|
|
||||||
// Router: /settings/edit/{id}
|
|
||||||
// In Router.php, params are passed to method.
|
|
||||||
// So method signature should be edit($id)
|
|
||||||
|
|
||||||
// Wait, Router.php passes matches as params array to invokeCallback.
|
|
||||||
// So we need to capture arguments here.
|
|
||||||
$args = func_get_args();
|
|
||||||
$id = $args[0] ?? null;
|
|
||||||
|
|
||||||
if (!$id) {
|
|
||||||
header('Location: /settings/routers');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$configModel = new Config();
|
|
||||||
$session = $configModel->getSessionById($id);
|
|
||||||
|
|
||||||
if (!$session) {
|
|
||||||
header('Location: /settings/routers');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->view('settings/form', ['router' => $session]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update() {
|
public function update() {
|
||||||
$id = $_POST['id'];
|
$id = $_POST['id'];
|
||||||
@@ -316,7 +286,7 @@ class SettingsController extends Controller {
|
|||||||
// Restore Logos
|
// Restore Logos
|
||||||
if (isset($json['logos'])) {
|
if (isset($json['logos'])) {
|
||||||
$logoModel = new \App\Models\Logo();
|
$logoModel = new \App\Models\Logo();
|
||||||
$uploadDir = ROOT . '/public/assets/img/logos/';
|
$uploadDir = ROOT . '/public/uploads/logos/';
|
||||||
if (!file_exists($uploadDir)) {
|
if (!file_exists($uploadDir)) {
|
||||||
mkdir($uploadDir, 0777, true);
|
mkdir($uploadDir, 0777, true);
|
||||||
}
|
}
|
||||||
@@ -341,7 +311,7 @@ class SettingsController extends Controller {
|
|||||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, path=excluded.path, type=excluded.type, size=excluded.size", [
|
ON CONFLICT(id) DO UPDATE SET name=excluded.name, path=excluded.path, type=excluded.type, size=excluded.size", [
|
||||||
'id' => $logo['id'],
|
'id' => $logo['id'],
|
||||||
'name' => $logo['name'],
|
'name' => $logo['name'],
|
||||||
'path' => '/assets/img/logos/' . $filename,
|
'path' => '/uploads/logos/' . $filename,
|
||||||
'type' => $extension,
|
'type' => $extension,
|
||||||
'size' => $logo['size']
|
'size' => $logo['size']
|
||||||
]);
|
]);
|
||||||
@@ -371,22 +341,24 @@ class SettingsController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function uploadLogo() {
|
public function uploadLogo() {
|
||||||
if (!isset($_FILES['logo_file'])) {
|
if (!isset($_FILES['logo_file']) || $_FILES['logo_file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
|
||||||
header('Location: /settings/logos');
|
header('Location: /settings/logos');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$logoModel = new \App\Models\Logo();
|
$logoModel = new \App\Models\Logo();
|
||||||
try {
|
try {
|
||||||
$logoModel->add($_FILES['logo_file']);
|
$result = $logoModel->add($_FILES['logo_file']);
|
||||||
|
if ($result) {
|
||||||
|
\App\Helpers\FlashHelper::set('success', 'toasts.logo_uploaded', 'toasts.logo_uploaded_desc', [], true);
|
||||||
|
} else {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Generic upload error', [], true);
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Ideally flash error message to session
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', $e->getMessage(), [], true);
|
||||||
// For now, redirect (logging error via debug or ignoring as per simple req)
|
|
||||||
// session_start() is implicit in Middleware usually or index
|
|
||||||
// $_SESSION['error'] = $e->getMessage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
\App\Helpers\FlashHelper::set('success', 'toasts.logo_uploaded', 'toasts.logo_uploaded_desc', [], true);
|
|
||||||
header('Location: /settings/logos');
|
header('Location: /settings/logos');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use App\Core\Controller;
|
|||||||
use App\Models\VoucherTemplateModel;
|
use App\Models\VoucherTemplateModel;
|
||||||
use App\Core\Middleware;
|
use App\Core\Middleware;
|
||||||
|
|
||||||
class TemplateController extends Controller {
|
class VoucherTemplateController extends Controller {
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
Middleware::auth();
|
Middleware::auth();
|
||||||
@@ -19,7 +19,7 @@ class TemplateController extends Controller {
|
|||||||
$data = [
|
$data = [
|
||||||
'templates' => $templates
|
'templates' => $templates
|
||||||
];
|
];
|
||||||
return $this->view('settings/templates/index', $data);
|
return $this->view('settings/voucher_templates/index', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function preview($id) {
|
public function preview($id) {
|
||||||
@@ -48,7 +48,7 @@ class TemplateController extends Controller {
|
|||||||
$data = [
|
$data = [
|
||||||
'logoMap' => $logoMap
|
'logoMap' => $logoMap
|
||||||
];
|
];
|
||||||
return $this->view('settings/templates/add', $data); // Note: add.php likely includes edit.php or is alias. View above says 'Template Editor (Shared)'
|
return $this->view('settings/voucher_templates/add', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store() {
|
public function store() {
|
||||||
@@ -62,6 +62,7 @@ class TemplateController extends Controller {
|
|||||||
// I will use 'global' for templates created in Settings.
|
// I will use 'global' for templates created in Settings.
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
|
'router_id' => 0, // Global templates
|
||||||
'session_name' => 'global',
|
'session_name' => 'global',
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'content' => $content
|
'content' => $content
|
||||||
@@ -71,7 +72,7 @@ class TemplateController extends Controller {
|
|||||||
$templateModel->add($data);
|
$templateModel->add($data);
|
||||||
|
|
||||||
\App\Helpers\FlashHelper::set('success', 'toasts.template_created', 'toasts.template_created_desc', ['name' => $name], true);
|
\App\Helpers\FlashHelper::set('success', 'toasts.template_created', 'toasts.template_created_desc', ['name' => $name], true);
|
||||||
header("Location: /settings/templates");
|
header("Location: /settings/voucher-templates");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ class TemplateController extends Controller {
|
|||||||
$template = $templateModel->getById($id);
|
$template = $templateModel->getById($id);
|
||||||
|
|
||||||
if (!$template) {
|
if (!$template) {
|
||||||
header("Location: /settings/templates");
|
header("Location: /settings/voucher-templates");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ class TemplateController extends Controller {
|
|||||||
'template' => $template,
|
'template' => $template,
|
||||||
'logoMap' => $logoMap
|
'logoMap' => $logoMap
|
||||||
];
|
];
|
||||||
return $this->view('settings/templates/edit', $data);
|
return $this->view('settings/voucher_templates/edit', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update() {
|
public function update() {
|
||||||
@@ -114,7 +115,7 @@ class TemplateController extends Controller {
|
|||||||
$templateModel->update($id, $data);
|
$templateModel->update($id, $data);
|
||||||
|
|
||||||
\App\Helpers\FlashHelper::set('success', 'toasts.template_updated', 'toasts.template_updated_desc', ['name' => $name], true);
|
\App\Helpers\FlashHelper::set('success', 'toasts.template_updated', 'toasts.template_updated_desc', ['name' => $name], true);
|
||||||
header("Location: /settings/templates");
|
header("Location: /settings/voucher-templates");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ class TemplateController extends Controller {
|
|||||||
$templateModel->delete($id);
|
$templateModel->delete($id);
|
||||||
|
|
||||||
\App\Helpers\FlashHelper::set('success', 'toasts.template_deleted', 'toasts.template_deleted_desc', [], true);
|
\App\Helpers\FlashHelper::set('success', 'toasts.template_deleted', 'toasts.template_deleted_desc', [], true);
|
||||||
header("Location: /settings/templates");
|
header("Location: /settings/voucher-templates");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ class Console {
|
|||||||
|
|
||||||
private function printBanner() {
|
private function printBanner() {
|
||||||
echo "\n";
|
echo "\n";
|
||||||
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.0" . self::COLOR_RESET . "\n\n";
|
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.1.0" . self::COLOR_RESET . "\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
private function commandServe($args) {
|
private function commandServe($args) {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class Migrations {
|
|||||||
// 6. Quick Prints (Voucher Printing Profiles)
|
// 6. Quick Prints (Voucher Printing Profiles)
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS quick_prints (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS quick_prints (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
router_id INTEGER,
|
||||||
session_name TEXT NOT NULL,
|
session_name TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
server TEXT NOT NULL,
|
server TEXT NOT NULL,
|
||||||
@@ -68,6 +69,7 @@ class Migrations {
|
|||||||
prefix TEXT DEFAULT '',
|
prefix TEXT DEFAULT '',
|
||||||
char_length INTEGER DEFAULT 4,
|
char_length INTEGER DEFAULT 4,
|
||||||
price INTEGER DEFAULT 0,
|
price INTEGER DEFAULT 0,
|
||||||
|
selling_price INTEGER DEFAULT 0,
|
||||||
time_limit TEXT DEFAULT '',
|
time_limit TEXT DEFAULT '',
|
||||||
data_limit TEXT DEFAULT '',
|
data_limit TEXT DEFAULT '',
|
||||||
comment TEXT DEFAULT '',
|
comment TEXT DEFAULT '',
|
||||||
@@ -79,6 +81,7 @@ class Migrations {
|
|||||||
// 7. Voucher Templates
|
// 7. Voucher Templates
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS voucher_templates (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS voucher_templates (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
router_id INTEGER,
|
||||||
session_name TEXT NOT NULL,
|
session_name TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
|
|||||||
@@ -4,13 +4,91 @@ namespace App\Core;
|
|||||||
|
|
||||||
class Router {
|
class Router {
|
||||||
protected $routes = [];
|
protected $routes = [];
|
||||||
|
protected $currentGroupMiddleware = [];
|
||||||
|
protected $lastRouteKey = null;
|
||||||
|
|
||||||
|
protected $middlewareAliases = [
|
||||||
|
'auth' => \App\Middleware\AuthMiddleware::class,
|
||||||
|
'cors' => \App\Middleware\CorsMiddleware::class,
|
||||||
|
'router.valid' => \App\Middleware\RouterCheckMiddleware::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a GET route
|
||||||
|
*/
|
||||||
public function get($path, $callback) {
|
public function get($path, $callback) {
|
||||||
$this->routes['GET'][$path] = $callback;
|
return $this->addRoute('GET', $path, $callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a POST route
|
||||||
|
*/
|
||||||
public function post($path, $callback) {
|
public function post($path, $callback) {
|
||||||
$this->routes['POST'][$path] = $callback;
|
return $this->addRoute('POST', $path, $callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add route to collection and return $this for chaining
|
||||||
|
*/
|
||||||
|
protected function addRoute($method, $path, $callback) {
|
||||||
|
$path = $this->normalizePath($path);
|
||||||
|
|
||||||
|
$this->routes[$method][$path] = [
|
||||||
|
'callback' => $callback,
|
||||||
|
'middleware' => $this->currentGroupMiddleware // Inherit group middleware
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->lastRouteKey = ['method' => $method, 'path' => $path];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach middleware to the last defined route
|
||||||
|
*/
|
||||||
|
public function middleware($names) {
|
||||||
|
if (!$this->lastRouteKey) return $this;
|
||||||
|
|
||||||
|
$method = $this->lastRouteKey['method'];
|
||||||
|
$path = $this->lastRouteKey['path'];
|
||||||
|
|
||||||
|
$middlewares = is_array($names) ? $names : [$names];
|
||||||
|
|
||||||
|
// Merge with existing middleware (from groups)
|
||||||
|
$this->routes[$method][$path]['middleware'] = array_merge(
|
||||||
|
$this->routes[$method][$path]['middleware'],
|
||||||
|
$middlewares
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a route group with shared attributes (middleware, prefix, etc.)
|
||||||
|
*/
|
||||||
|
public function group($attributes, callable $callback) {
|
||||||
|
$previousGroupMiddleware = $this->currentGroupMiddleware;
|
||||||
|
|
||||||
|
if (isset($attributes['middleware'])) {
|
||||||
|
$newMiddleware = is_array($attributes['middleware'])
|
||||||
|
? $attributes['middleware']
|
||||||
|
: [$attributes['middleware']];
|
||||||
|
|
||||||
|
$this->currentGroupMiddleware = array_merge(
|
||||||
|
$this->currentGroupMiddleware,
|
||||||
|
$newMiddleware
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the callback with $this router instance
|
||||||
|
$callback($this);
|
||||||
|
|
||||||
|
// Restore previous state
|
||||||
|
$this->currentGroupMiddleware = $previousGroupMiddleware;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizePath($path) {
|
||||||
|
return '/' . trim($path, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dispatch($uri, $method) {
|
public function dispatch($uri, $method) {
|
||||||
@@ -21,27 +99,24 @@ class Router {
|
|||||||
if (strpos($path, $scriptName) === 0) {
|
if (strpos($path, $scriptName) === 0) {
|
||||||
$path = substr($path, strlen($scriptName));
|
$path = substr($path, strlen($scriptName));
|
||||||
}
|
}
|
||||||
$path = '/' . trim($path, '/');
|
$path = $this->normalizePath($path);
|
||||||
|
|
||||||
// Global Install Check: Redirect if database is missing
|
// Global Install Check
|
||||||
$dbPath = ROOT . '/app/Database/database.sqlite';
|
$dbPath = ROOT . '/app/Database/database.sqlite';
|
||||||
if (!file_exists($dbPath)) {
|
if (!file_exists($dbPath)) {
|
||||||
// Whitelist /install route and assets to prevent infinite loop
|
|
||||||
if ($path !== '/install' && strpos($path, '/assets/') !== 0) {
|
if ($path !== '/install' && strpos($path, '/assets/') !== 0) {
|
||||||
header('Location: /install');
|
header('Location: /install');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check exact match first
|
// 1. Try Exact Match
|
||||||
if (isset($this->routes[$method][$path])) {
|
if (isset($this->routes[$method][$path])) {
|
||||||
$callback = $this->routes[$method][$path];
|
return $this->runRoute($this->routes[$method][$path], []);
|
||||||
return $this->invokeCallback($callback);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check dynamic routes
|
// 2. Try Dynamic Routes (Regex)
|
||||||
foreach ($this->routes[$method] as $route => $callback) {
|
foreach ($this->routes[$method] as $route => $config) {
|
||||||
// Convert route syntax to regex
|
|
||||||
// e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$#
|
// e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$#
|
||||||
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route);
|
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route);
|
||||||
$pattern = "#^" . $pattern . "$#";
|
$pattern = "#^" . $pattern . "$#";
|
||||||
@@ -49,13 +124,43 @@ class Router {
|
|||||||
if (preg_match($pattern, $path, $matches)) {
|
if (preg_match($pattern, $path, $matches)) {
|
||||||
array_shift($matches); // Remove full match
|
array_shift($matches); // Remove full match
|
||||||
$matches = array_map('urldecode', $matches);
|
$matches = array_map('urldecode', $matches);
|
||||||
return $this->invokeCallback($callback, $matches);
|
return $this->runRoute($config, $matches);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
\App\Helpers\ErrorHelper::show(404);
|
\App\Helpers\ErrorHelper::show(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function runRoute($routeConfig, $params) {
|
||||||
|
$callback = $routeConfig['callback'];
|
||||||
|
$middlewares = $routeConfig['middleware'];
|
||||||
|
|
||||||
|
// Pipeline Runner
|
||||||
|
$pipeline = array_reduce(
|
||||||
|
array_reverse($middlewares),
|
||||||
|
function ($nextStack, $middlewareName) {
|
||||||
|
return function ($request) use ($nextStack, $middlewareName) {
|
||||||
|
// Resolve Middleware Class
|
||||||
|
$class = $this->middlewareAliases[$middlewareName] ?? $middlewareName;
|
||||||
|
|
||||||
|
if (!class_exists($class)) {
|
||||||
|
throw new \Exception("Middleware class '$class' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance = new $class();
|
||||||
|
return $instance->handle($request, $nextStack);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
function ($request) use ($callback, $params) {
|
||||||
|
// Final destination: The Controller
|
||||||
|
return $this->invokeCallback($callback, $params);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start the pipeline with the current request (mock object or just null/path)
|
||||||
|
return $pipeline($_SERVER);
|
||||||
|
}
|
||||||
|
|
||||||
protected function invokeCallback($callback, $params = []) {
|
protected function invokeCallback($callback, $params = []) {
|
||||||
if (is_array($callback)) {
|
if (is_array($callback)) {
|
||||||
$controller = new $callback[0]();
|
$controller = new $callback[0]();
|
||||||
|
|||||||
@@ -7,21 +7,26 @@ class ErrorHelper {
|
|||||||
public static function show($code = 404, $message = 'Page Not Found', $description = null) {
|
public static function show($code = 404, $message = 'Page Not Found', $description = null) {
|
||||||
http_response_code($code);
|
http_response_code($code);
|
||||||
|
|
||||||
// Provide default descriptions for common codes
|
// Provide default translation keys for common codes
|
||||||
if ($description === null) {
|
if ($description === null) {
|
||||||
switch ($code) {
|
switch ($code) {
|
||||||
case 403:
|
case 403:
|
||||||
$description = "You do not have permission to access this resource.";
|
$message = ($message === 'Page Not Found') ? 'errors.403_title' : $message; // Override default if simple
|
||||||
|
$description = "errors.403_desc";
|
||||||
break;
|
break;
|
||||||
case 500:
|
case 500:
|
||||||
$description = "Something went wrong on our end. Please try again later.";
|
$message = ($message === 'Page Not Found') ? 'errors.500_title' : $message;
|
||||||
|
$description = "errors.500_desc";
|
||||||
break;
|
break;
|
||||||
case 503:
|
case 503:
|
||||||
$description = "Service Unavailable. The server is currently unable to handle the request due to maintenance or overload.";
|
$message = ($message === 'Page Not Found') ? 'errors.503_title' : $message;
|
||||||
|
$description = "errors.503_desc";
|
||||||
break;
|
break;
|
||||||
case 404:
|
case 404:
|
||||||
default:
|
default:
|
||||||
$description = "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.";
|
// If message is generic default, use key
|
||||||
|
if ($message === 'Page Not Found') $message = 'errors.404_title';
|
||||||
|
$description = "errors.404_desc";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/Middleware/AuthMiddleware.php
Normal file
15
app/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
class AuthMiddleware implements MiddlewareInterface {
|
||||||
|
public function handle($request, \Closure $next) {
|
||||||
|
// Assume session is started in index.php
|
||||||
|
if (!isset($_SESSION['user_id'])) {
|
||||||
|
header('Location: /login');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Middleware/CorsMiddleware.php
Normal file
39
app/Middleware/CorsMiddleware.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
class CorsMiddleware implements MiddlewareInterface {
|
||||||
|
public function handle($request, \Closure $next) {
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
|
||||||
|
// Always allow if no origin (e.g. server-to-server or same-origin strict)
|
||||||
|
// Check generic logic: if valid origin, try to match DB
|
||||||
|
if (!empty($origin)) {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->query("SELECT * FROM api_cors WHERE origin = ? OR origin = '*' LIMIT 1", [$origin]);
|
||||||
|
$rule = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($rule) {
|
||||||
|
header("Access-Control-Allow-Origin: " . ($rule['origin'] === '*' ? '*' : $origin));
|
||||||
|
|
||||||
|
$methods = json_decode($rule['methods'], true) ?: ['GET', 'POST'];
|
||||||
|
header("Access-Control-Allow-Methods: " . implode(', ', $methods));
|
||||||
|
|
||||||
|
$headers = json_decode($rule['headers'], true) ?: ['*'];
|
||||||
|
header("Access-Control-Allow-Headers: " . implode(', ', $headers));
|
||||||
|
|
||||||
|
header("Access-Control-Max-Age: " . ($rule['max_age'] ?? 3600));
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Middleware/MiddlewareInterface.php
Normal file
7
app/Middleware/MiddlewareInterface.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
interface MiddlewareInterface {
|
||||||
|
public function handle($request, \Closure $next);
|
||||||
|
}
|
||||||
41
app/Middleware/RouterCheckMiddleware.php
Normal file
41
app/Middleware/RouterCheckMiddleware.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Config;
|
||||||
|
|
||||||
|
class RouterCheckMiddleware implements MiddlewareInterface {
|
||||||
|
public function handle($request, \Closure $next) {
|
||||||
|
// We need to extract the session from the URI
|
||||||
|
// Pattern: /{session}/...
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$scriptName = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
if (strpos($path, $scriptName) === 0) {
|
||||||
|
$path = substr($path, strlen($scriptName));
|
||||||
|
}
|
||||||
|
$path = '/' . trim($path, '/');
|
||||||
|
|
||||||
|
// Regex to grab first segment
|
||||||
|
if (preg_match('#^/([^/]+)#', $path, $matches)) {
|
||||||
|
$session = $matches[1];
|
||||||
|
|
||||||
|
// Exclude system routes that might mimic this pattern if any (like 'settings')
|
||||||
|
// But 'settings' is usually top level.
|
||||||
|
// If the user name their router "settings", it would conflict anyway.
|
||||||
|
// Let's assume standard routing structure.
|
||||||
|
|
||||||
|
if ($session === 'login' || $session === 'logout' || $session === 'settings' || $session === 'install' || $session === 'api') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$configModel = new Config();
|
||||||
|
if ($session !== 'demo' && !$configModel->getSession($session)) {
|
||||||
|
// Router NOT FOUND
|
||||||
|
\App\Helpers\ErrorHelper::show(404, 'errors.router_not_found_title', 'errors.router_not_found_desc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ class Config {
|
|||||||
|
|
||||||
if ($router) {
|
if ($router) {
|
||||||
return [
|
return [
|
||||||
|
'id' => $router['id'],
|
||||||
'ip' => $router['ip_address'],
|
'ip' => $router['ip_address'],
|
||||||
'ip_address' => $router['ip_address'], // Alias
|
'ip_address' => $router['ip_address'], // Alias
|
||||||
'user' => $router['username'],
|
'user' => $router['username'],
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class Logo {
|
|||||||
$exists = $this->getById($id);
|
$exists = $this->getById($id);
|
||||||
} while ($exists);
|
} while ($exists);
|
||||||
|
|
||||||
$uploadDir = ROOT . '/public/assets/img/logos/';
|
$uploadDir = ROOT . '/public/uploads/logos/';
|
||||||
if (!file_exists($uploadDir)) {
|
if (!file_exists($uploadDir)) {
|
||||||
mkdir($uploadDir, 0777, true);
|
mkdir($uploadDir, 0777, true);
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ class Logo {
|
|||||||
$this->db->query("INSERT INTO {$this->table} (id, name, path, type, size) VALUES (:id, :name, :path, :type, :size)", [
|
$this->db->query("INSERT INTO {$this->table} (id, name, path, type, size) VALUES (:id, :name, :path, :type, :size)", [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'name' => $file['name'],
|
'name' => $file['name'],
|
||||||
'path' => '/assets/img/logos/' . $filename,
|
'path' => '/uploads/logos/' . $filename,
|
||||||
'type' => $extension,
|
'type' => $extension,
|
||||||
'size' => $file['size']
|
'size' => $file['size']
|
||||||
]);
|
]);
|
||||||
@@ -98,7 +98,7 @@ class Logo {
|
|||||||
|
|
||||||
public function syncFiles() {
|
public function syncFiles() {
|
||||||
// One-time sync: scan folder, if file not in DB, add it.
|
// One-time sync: scan folder, if file not in DB, add it.
|
||||||
$logoDir = ROOT . '/public/assets/img/logos/';
|
$logoDir = ROOT . '/public/uploads/logos/';
|
||||||
if (!file_exists($logoDir)) return;
|
if (!file_exists($logoDir)) return;
|
||||||
|
|
||||||
$files = [];
|
$files = [];
|
||||||
@@ -112,7 +112,7 @@ class Logo {
|
|||||||
$extension = pathinfo($filename, PATHINFO_EXTENSION);
|
$extension = pathinfo($filename, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
// Check if file is registered (maybe by path match)
|
// Check if file is registered (maybe by path match)
|
||||||
$webPath = '/assets/img/logos/' . $filename;
|
$webPath = '/uploads/logos/' . $filename;
|
||||||
$stmt = $this->db->query("SELECT COUNT(*) FROM {$this->table} WHERE path = :path", ['path' => $webPath]);
|
$stmt = $this->db->query("SELECT COUNT(*) FROM {$this->table} WHERE path = :path", ['path' => $webPath]);
|
||||||
|
|
||||||
if ($stmt->fetchColumn() == 0) {
|
if ($stmt->fetchColumn() == 0) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use App\Core\Database;
|
|||||||
|
|
||||||
class QuickPrintModel {
|
class QuickPrintModel {
|
||||||
|
|
||||||
public function getAllBySession($sessionName) {
|
public function getAllByRouterId($routerId) {
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$stmt = $db->query("SELECT * FROM quick_prints WHERE session_name = ?", [$sessionName]);
|
$stmt = $db->query("SELECT * FROM quick_prints WHERE router_id = ?", [$routerId]);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,17 +20,22 @@ class QuickPrintModel {
|
|||||||
|
|
||||||
public function add($data) {
|
public function add($data) {
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$sql = "INSERT INTO quick_prints (session_name, name, server, profile, prefix, char_length, price, time_limit, data_limit, comment, color)
|
// Insert router_id. session_name is kept for legacy/redundancy if needed, or we can drop it.
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
// Let's write both for now to be safe during transition, or user requirement "diubah saja" implies replacement using ID.
|
||||||
|
// But the table still has session_name column (we added router_id, didn't drop session_name).
|
||||||
|
$sql = "INSERT INTO quick_prints (router_id, session_name, name, server, profile, prefix, char_length, price, selling_price, time_limit, data_limit, comment, color)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
return $db->query($sql, [
|
return $db->query($sql, [
|
||||||
$data['session_name'],
|
$data['router_id'],
|
||||||
|
$data['session_name'], // Keep filling it for now
|
||||||
$data['name'],
|
$data['name'],
|
||||||
$data['server'],
|
$data['server'] ?? 'all',
|
||||||
$data['profile'],
|
$data['profile'],
|
||||||
$data['prefix'] ?? '',
|
$data['prefix'] ?? '',
|
||||||
$data['char_length'] ?? 4,
|
$data['char_length'] ?? 4,
|
||||||
$data['price'] ?? 0,
|
$data['price'] ?? 0,
|
||||||
|
$data['selling_price'] ?? ($data['price'] ?? 0),
|
||||||
$data['time_limit'] ?? '',
|
$data['time_limit'] ?? '',
|
||||||
$data['data_limit'] ?? '',
|
$data['data_limit'] ?? '',
|
||||||
$data['comment'] ?? '',
|
$data['comment'] ?? '',
|
||||||
@@ -40,15 +45,15 @@ class QuickPrintModel {
|
|||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data) {
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$sql = "UPDATE quick_prints SET name=?, server=?, profile=?, prefix=?, char_length=?, price=?, time_limit=?, data_limit=?, comment=?, color=?, updated_at=CURRENT_TIMESTAMP WHERE id=?";
|
$sql = "UPDATE quick_prints SET name=?, profile=?, prefix=?, char_length=?, price=?, selling_price=?, time_limit=?, data_limit=?, comment=?, color=?, updated_at=CURRENT_TIMESTAMP WHERE id=?";
|
||||||
|
|
||||||
return $db->query($sql, [
|
return $db->query($sql, [
|
||||||
$data['name'],
|
$data['name'],
|
||||||
$data['server'],
|
|
||||||
$data['profile'],
|
$data['profile'],
|
||||||
$data['prefix'] ?? '',
|
$data['prefix'] ?? '',
|
||||||
$data['char_length'] ?? 4,
|
$data['char_length'] ?? 4,
|
||||||
$data['price'] ?? 0,
|
$data['price'] ?? 0,
|
||||||
|
$data['selling_price'] ?? ($data['price'] ?? 0),
|
||||||
$data['time_limit'] ?? '',
|
$data['time_limit'] ?? '',
|
||||||
$data['data_limit'] ?? '',
|
$data['data_limit'] ?? '',
|
||||||
$data['comment'] ?? '',
|
$data['comment'] ?? '',
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ class VoucherTemplateModel {
|
|||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBySession($sessionName) {
|
public function getAllByRouterId($routerId) {
|
||||||
// Templates can be global or session specific, but allow session filtering
|
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$stmt = $db->query("SELECT * FROM voucher_templates WHERE session_name = ? OR session_name = 'global'", [$sessionName]);
|
$stmt = $db->query("SELECT * FROM voucher_templates WHERE router_id = ?", [$routerId]);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +26,9 @@ class VoucherTemplateModel {
|
|||||||
|
|
||||||
public function add($data) {
|
public function add($data) {
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$sql = "INSERT INTO voucher_templates (session_name, name, content) VALUES (?, ?, ?)";
|
$sql = "INSERT INTO voucher_templates (router_id, session_name, name, content) VALUES (?, ?, ?, ?)";
|
||||||
return $db->query($sql, [
|
return $db->query($sql, [
|
||||||
|
$data['router_id'],
|
||||||
$data['session_name'],
|
$data['session_name'],
|
||||||
$data['name'],
|
$data['name'],
|
||||||
$data['content']
|
$data['content']
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
// Init
|
// Init
|
||||||
fetchInterfaces().then(() => {
|
fetchInterfaces().then(() => {
|
||||||
// Start Polling after interfaces loaded
|
// Start Polling after interfaces loaded
|
||||||
setInterval(fetchTraffic, 5000); // Every 5 seconds
|
const reloadInterval = <?= ($reload_interval ?? 5) * 1000 ?>; // Convert sec to ms
|
||||||
|
setInterval(fetchTraffic, reloadInterval);
|
||||||
fetchTraffic();
|
fetchTraffic();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,17 +16,21 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-6xl font-extrabold tracking-tighter mb-4 text-foreground"><?= $errorCode ?></h1>
|
<h1 class="text-6xl font-extrabold tracking-tighter mb-4 text-foreground"><?= $errorCode ?></h1>
|
||||||
<h2 class="text-2xl font-bold mb-4 text-foreground"><?= $errorMessage ?></h2>
|
|
||||||
|
|
||||||
<p class="text-accents-5 max-w-md mx-auto mb-8">
|
<!-- Use data-i18n if message looks like a key (starts with errors.), otherwise show raw -->
|
||||||
|
<h2 class="text-2xl font-bold mb-4 text-foreground" <?= (strpos($errorMessage, 'errors.') === 0) ? 'data-i18n="'.$errorMessage.'"' : '' ?>>
|
||||||
|
<?= $errorMessage ?>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-accents-5 max-w-md mx-auto mb-8" <?= (strpos($errorDescription, 'errors.') === 0) ? 'data-i18n="'.$errorDescription.'"' : '' ?>>
|
||||||
<?= $errorDescription ?>
|
<?= $errorDescription ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row justify-center gap-4 w-full sm:w-auto">
|
<div class="flex flex-col sm:flex-row justify-center gap-4 w-full sm:w-auto">
|
||||||
<a href="/" class="btn btn-primary w-full sm:w-auto">
|
<a href="/" class="btn btn-primary w-full sm:w-auto" data-i18n="errors.return_home">
|
||||||
Return Home
|
Return Home
|
||||||
</a>
|
</a>
|
||||||
<button onclick="history.back()" class="btn btn-secondary w-full sm:w-auto">
|
<button onclick="history.back()" class="btn btn-secondary w-full sm:w-auto" data-i18n="errors.go_back">
|
||||||
Go Back
|
Go Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -22,9 +22,9 @@ sort($uniqueModes);
|
|||||||
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
|
<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>
|
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/add" class="btn btn-primary">
|
<button onclick="openProfileModal('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>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_profiles.add_profile">Add Profile</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,8 +79,21 @@ sort($uniqueModes);
|
|||||||
<tbody id="table-body">
|
<tbody id="table-body">
|
||||||
<?php if (!empty($profiles)): ?>
|
<?php if (!empty($profiles)): ?>
|
||||||
<?php foreach ($profiles as $profile): ?>
|
<?php foreach ($profiles as $profile): ?>
|
||||||
<tr class="table-row-item"
|
<tr class="table-row-item group-row"
|
||||||
data-name="<?= strtolower($profile['name'] ?? '') ?>"
|
data-id="<?= $profile['.id'] ?>"
|
||||||
|
data-name="<?= htmlspecialchars($profile['name'] ?? '') ?>"
|
||||||
|
data-shared-users="<?= htmlspecialchars($profile['shared-users'] ?? '1') ?>"
|
||||||
|
data-rate-limit="<?= htmlspecialchars($profile['rate-limit'] ?? '') ?>"
|
||||||
|
data-address-pool="<?= htmlspecialchars($profile['address-pool'] ?? 'none') ?>"
|
||||||
|
data-parent-queue="<?= htmlspecialchars($profile['parent-queue'] ?? 'none') ?>"
|
||||||
|
data-expired-mode="<?= htmlspecialchars($profile['meta']['expired_mode'] ?? 'none') ?>"
|
||||||
|
data-val-d="<?= htmlspecialchars($profile['val_d'] ?? '') ?>"
|
||||||
|
data-val-h="<?= htmlspecialchars($profile['val_h'] ?? '') ?>"
|
||||||
|
data-val-m="<?= htmlspecialchars($profile['val_m'] ?? '') ?>"
|
||||||
|
data-price="<?= htmlspecialchars($profile['meta']['price'] ?? '') ?>"
|
||||||
|
data-selling-price="<?= htmlspecialchars($profile['meta']['selling_price'] ?? '') ?>"
|
||||||
|
data-lock-user="<?= htmlspecialchars($profile['meta']['lock_user'] ?? 'Disable') ?>"
|
||||||
|
data-search-name="<?= strtolower($profile['name'] ?? '') ?>"
|
||||||
data-mode="<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>">
|
data-mode="<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>">
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
@@ -89,9 +102,9 @@ sort($uniqueModes);
|
|||||||
<i data-lucide="ticket" class="w-4 h-4"></i>
|
<i data-lucide="ticket" class="w-4 h-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-medium text-foreground">
|
<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">
|
<button onclick="openProfileModal('edit', this)" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400 text-left">
|
||||||
<?= htmlspecialchars($profile['name'] ?? '-') ?>
|
<?= htmlspecialchars($profile['name'] ?? '-') ?>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -129,9 +142,9 @@ sort($uniqueModes);
|
|||||||
|
|
||||||
<td class="text-right text-sm font-medium">
|
<td class="text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
<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">
|
<button onclick="openProfileModal('edit', this)" 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>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</a>
|
</button>
|
||||||
<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">
|
<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="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
<input type="hidden" name="id" value="<?= $profile['.id'] ?>">
|
<input type="hidden" name="id" value="<?= $profile['.id'] ?>">
|
||||||
@@ -238,7 +251,7 @@ sort($uniqueModes);
|
|||||||
|
|
||||||
update() {
|
update() {
|
||||||
this.filteredRows = this.allRows.filter(row => {
|
this.filteredRows = this.allRows.filter(row => {
|
||||||
const name = row.dataset.name || '';
|
const name = row.dataset.searchName || '';
|
||||||
const mode = row.dataset.mode || '';
|
const mode = row.dataset.mode || '';
|
||||||
|
|
||||||
if (this.filters.search && !name.includes(this.filters.search)) return false;
|
if (this.filters.search && !name.includes(this.filters.search)) return false;
|
||||||
@@ -308,4 +321,200 @@ sort($uniqueModes);
|
|||||||
const rows = document.querySelectorAll('.table-row-item');
|
const rows = document.querySelectorAll('.table-row-item');
|
||||||
new TableManager(rows, 10);
|
new TableManager(rows, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openProfileModal(mode, btn = null) {
|
||||||
|
const template = document.getElementById('profile-form-template').innerHTML;
|
||||||
|
|
||||||
|
let title = window.i18n ? window.i18n.t('hotspot_profiles.form.add_title') : 'Add Profile';
|
||||||
|
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
title = window.i18n ? window.i18n.t('hotspot_profiles.form.edit_title') : 'Edit Profile';
|
||||||
|
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
|
||||||
|
}
|
||||||
|
|
||||||
|
const preConfirmFn = () => {
|
||||||
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
const form = popup.querySelector('form');
|
||||||
|
|
||||||
|
// Validity Toggle Logic for Modal
|
||||||
|
const modeSelect = form.querySelector('#expired-mode');
|
||||||
|
const validityGroup = form.querySelector('#validity-group');
|
||||||
|
|
||||||
|
function toggleValidity() {
|
||||||
|
if (!modeSelect || !validityGroup) return;
|
||||||
|
if (modeSelect.value === 'none') {
|
||||||
|
validityGroup.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
validityGroup.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeSelect) {
|
||||||
|
modeSelect.addEventListener('change', toggleValidity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
|
||||||
|
form.action = "/<?= htmlspecialchars($session) ?>/hotspot/profile/update";
|
||||||
|
|
||||||
|
// Populate Hidden ID
|
||||||
|
const idInput = form.querySelector('#form-id');
|
||||||
|
idInput.disabled = false;
|
||||||
|
idInput.value = row.dataset.id;
|
||||||
|
|
||||||
|
// Populate Fields
|
||||||
|
form.querySelector('[name="name"]').value = row.dataset.name || '';
|
||||||
|
form.querySelector('[name="shared-users"]').value = row.dataset.sharedUsers || '1';
|
||||||
|
form.querySelector('[name="rate-limit"]').value = row.dataset.rateLimit || '';
|
||||||
|
|
||||||
|
// Selects
|
||||||
|
if(form.querySelector('[name="address-pool"]')) form.querySelector('[name="address-pool"]').value = row.dataset.addressPool;
|
||||||
|
if(form.querySelector('[name="parent-queue"]')) form.querySelector('[name="parent-queue"]').value = row.dataset.parentQueue;
|
||||||
|
if(form.querySelector('[name="expired_mode"]')) form.querySelector('[name="expired_mode"]').value = row.dataset.expiredMode;
|
||||||
|
if(form.querySelector('[name="lock_user"]')) form.querySelector('[name="lock_user"]').value = row.dataset.lockUser;
|
||||||
|
|
||||||
|
// Validity
|
||||||
|
form.querySelector('[name="validity_d"]').value = row.dataset.valD || '';
|
||||||
|
form.querySelector('[name="validity_h"]').value = row.dataset.valH || '';
|
||||||
|
form.querySelector('[name="validity_m"]').value = row.dataset.valM || '';
|
||||||
|
|
||||||
|
// Prices
|
||||||
|
form.querySelector('[name="price"]').value = row.dataset.price || '';
|
||||||
|
form.querySelector('[name="selling_price"]').value = row.dataset.sellingPrice || '';
|
||||||
|
|
||||||
|
// Initial Toggle Check
|
||||||
|
toggleValidity();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template id="profile-form-template">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 text-left">
|
||||||
|
<!-- Form Column -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<form id="profile-form" action="/<?= htmlspecialchars($session) ?>/hotspot/profile/store" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
|
<input type="hidden" name="id" id="form-id" disabled>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="common.name">Name</label>
|
||||||
|
<input type="text" name="name" required class="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="form-label" data-i18n="hotspot_profiles.form.address_pool">Address Pool</label>
|
||||||
|
<select name="address-pool" class="w-full">
|
||||||
|
<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="form-label" data-i18n="hotspot_profiles.form.shared_users">Shared Users</label>
|
||||||
|
<input type="number" name="shared-users" value="1" min="1" class="w-full" placeholder="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate Limit & Parent Queue -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.rate_limit">Rate Limit (Rx/Tx)</label>
|
||||||
|
<input type="text" name="rate-limit" class="w-full" data-i18n-placeholder="hotspot_profiles.form.rate_limit_help" placeholder="e.g. 512k/1M">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.parent_queue">Parent Queue</label>
|
||||||
|
<select name="parent-queue" class="w-full">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Expired Mode & Validity -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.expired_mode">Expired Mode</label>
|
||||||
|
<select name="expired_mode" id="expired-mode" class="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>
|
||||||
|
</div>
|
||||||
|
<div id="validity-group" class="hidden space-y-1 transition-all">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.validity">Validity</label>
|
||||||
|
<div class="flex w-full">
|
||||||
|
<input type="number" name="validity_d" min="0" class="w-full text-center rounded-r-none border-r-0" placeholder="0D">
|
||||||
|
<input type="number" name="validity_h" min="0" class="w-full text-center rounded-none border-r-0" placeholder="0H">
|
||||||
|
<input type="number" name="validity_m" min="0" class="w-full text-center rounded-l-none" placeholder="0M">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prices -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.price">Price (Rp)</label>
|
||||||
|
<input type="number" name="price" class="w-full" placeholder="e.g. 5000">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.selling_price">Selling Price (Rp)</label>
|
||||||
|
<input type="number" name="selling_price" class="w-full" placeholder="e.g. 7000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lock User -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.lock_user">Lock User</label>
|
||||||
|
<select name="lock_user" class="w-full">
|
||||||
|
<option value="Disable" data-i18n="common.forms.disabled">Disable</option>
|
||||||
|
<option value="Enable" data-i18n="common.forms.enabled">Enable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-12"></div> <!-- Spacer for selects -->
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips Column -->
|
||||||
|
<div class="hidden lg:block space-y-4 border-l border-white/10 pl-6">
|
||||||
|
<h3 class="text-sm font-bold text-foreground flex items-center gap-2">
|
||||||
|
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
|
||||||
|
<span data-i18n="hotspot_profiles.form.quick_tips">Quick Tips</span>
|
||||||
|
</h3>
|
||||||
|
<ul class="text-xs text-accents-5 space-y-3 list-disc pl-4">
|
||||||
|
<li data-i18n="hotspot_profiles.form.tip_rate_limit">
|
||||||
|
<strong>Rate Limit</strong>: Rx/Tx (Upload/Download). Example: <code>512k/1M</code>
|
||||||
|
</li>
|
||||||
|
<li data-i18n="hotspot_profiles.form.tip_expired_mode">
|
||||||
|
<strong>Expired Mode</strong>: Select 'Remove' or 'Notice' to enable Validity.
|
||||||
|
</li>
|
||||||
|
<li data-i18n="hotspot_profiles.form.tip_parent_queue">
|
||||||
|
<strong>Parent Queue</strong>: Assigns users to a specific parent queue for bandwidth management.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<?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'; ?>
|
|
||||||
@@ -15,6 +15,10 @@ if (!empty($users)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort($uniqueProfiles);
|
sort($uniqueProfiles);
|
||||||
|
|
||||||
|
// $servers is passed from controller
|
||||||
|
if (!isset($servers)) $servers = [];
|
||||||
|
|
||||||
sort($uniqueComments);
|
sort($uniqueComments);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -27,9 +31,9 @@ sort($uniqueComments);
|
|||||||
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
|
<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>
|
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/add" class="btn btn-primary">
|
<button onclick="openUserModal('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>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_users.add_user">Add User</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,13 +111,38 @@ sort($uniqueComments);
|
|||||||
<tbody id="table-body">
|
<tbody id="table-body">
|
||||||
<?php if (!empty($users)): ?>
|
<?php if (!empty($users)): ?>
|
||||||
<?php foreach ($users as $user): ?>
|
<?php foreach ($users as $user): ?>
|
||||||
|
<?php
|
||||||
|
// Helper to split time limit for editing (Simple parsing or raw passing)
|
||||||
|
// Assuming time limit format from router is like 1d2h3m or just 1h
|
||||||
|
// We will pass the raw string if we can't easily split, OR rely on a JS parser.
|
||||||
|
// For now let's pass raw limit-uptime.
|
||||||
|
|
||||||
|
// Just prepare some safe values
|
||||||
|
$id = $user['.id'];
|
||||||
|
$name = $user['name'] ?? '';
|
||||||
|
$profile = $user['profile'] ?? 'default';
|
||||||
|
$comment = $user['comment'] ?? '';
|
||||||
|
$server = $user['server'] ?? 'all';
|
||||||
|
$password = $user['password'] ?? '';
|
||||||
|
|
||||||
|
// Limits
|
||||||
|
$limitUptime = $user['limit-uptime'] ?? '';
|
||||||
|
$limitBytes = $user['limit-bytes-total'] ?? '';
|
||||||
|
?>
|
||||||
<tr class="table-row-item"
|
<tr class="table-row-item"
|
||||||
data-name="<?= strtolower($user['name'] ?? '') ?>"
|
data-id="<?= htmlspecialchars($id) ?>"
|
||||||
data-profile="<?= $user['profile'] ?? 'default' ?>"
|
data-name="<?= strtolower($name) ?>"
|
||||||
data-comment="<?= htmlspecialchars($user['comment'] ?? '') ?>">
|
data-rawname="<?= htmlspecialchars($name) ?>"
|
||||||
|
data-profile="<?= htmlspecialchars($profile) ?>"
|
||||||
|
data-comment="<?= htmlspecialchars($comment) ?>"
|
||||||
|
data-comment-raw="<?= htmlspecialchars($comment) ?>"
|
||||||
|
data-password="<?= htmlspecialchars($password) ?>"
|
||||||
|
data-server="<?= htmlspecialchars($server) ?>"
|
||||||
|
data-limit-uptime="<?= htmlspecialchars($limitUptime) ?>"
|
||||||
|
data-limit-bytes-total="<?= htmlspecialchars($limitBytes) ?>">
|
||||||
|
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<input type="checkbox" name="selected_users[]" value="<?= htmlspecialchars($user['.id']) ?>" class="user-checkbox checkbox">
|
<input type="checkbox" name="selected_users[]" value="<?= htmlspecialchars($id) ?>" class="user-checkbox checkbox">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center w-full">
|
<div class="flex items-center w-full">
|
||||||
@@ -122,19 +151,19 @@ sort($uniqueComments);
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($user['name'] ?? '-') ?></div>
|
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($name) ?></div>
|
||||||
<?php
|
<?php
|
||||||
$status = \App\Helpers\HotspotHelper::getUserStatus($user);
|
$status = \App\Helpers\HotspotHelper::getUserStatus($user);
|
||||||
echo \App\Helpers\ViewHelper::badge($status);
|
echo \App\Helpers\ViewHelper::badge($status);
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-accents-5"><?= htmlspecialchars($user['password'] ?? '******') ?></div>
|
<div class="text-xs text-accents-5"><?= htmlspecialchars($password) ?></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<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">
|
<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') ?>
|
<?= htmlspecialchars($profile) ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -148,19 +177,19 @@ sort($uniqueComments);
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm text-accents-5 italic"><?= htmlspecialchars($user['comment'] ?? '-') ?></div>
|
<div class="text-sm text-accents-5 italic"><?= htmlspecialchars($comment) ?></div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right text-sm font-medium">
|
<td class="text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||||
<button onclick="printUser('<?= htmlspecialchars($user['.id']) ?>')" class="btn-icon" title="Print">
|
<button onclick="printUser('<?= htmlspecialchars($id) ?>')" class="btn-icon" title="Print">
|
||||||
<i data-lucide="printer" class="w-4 h-4"></i>
|
<i data-lucide="printer" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/user/edit/<?= urlencode($user['.id']) ?>" class="btn-icon inline-flex items-center justify-center" title="Edit">
|
<button onclick="openUserModal('edit', this)" class="btn-icon inline-flex items-center justify-center" title="Edit">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</a>
|
</button>
|
||||||
<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">
|
<form action="/<?= htmlspecialchars($session) ?>/hotspot/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Delete User?', 'Are you sure you want to delete user <?= htmlspecialchars($name) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||||
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
<input type="hidden" name="id" value="<?= $user['.id'] ?>">
|
<input type="hidden" name="id" value="<?= $id ?>">
|
||||||
<button type="submit" class="btn-icon-danger" title="Delete">
|
<button type="submit" class="btn-icon-danger" title="Delete">
|
||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -188,6 +217,133 @@ sort($uniqueComments);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
|
<!-- Add/Edit User Template -->
|
||||||
|
<template id="user-form-template">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 text-left">
|
||||||
|
<!-- Form Column -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<form id="user-form" action="/<?= htmlspecialchars($session) ?>/hotspot/store" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
|
<input type="hidden" name="id" id="form-id" disabled> <!-- Disabled for Add -->
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Name & Password -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.username">Username</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-icon"><i data-lucide="user" class="w-4 h-4"></i></span>
|
||||||
|
<input type="text" name="name" required class="pl-10 w-full" data-i18n-placeholder="hotspot_users.form.username_placeholder" placeholder="e.g. voucher123">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.password">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-icon"><i data-lucide="key" class="w-4 h-4"></i></span>
|
||||||
|
<input type="text" name="password" required class="pl-10 w-full" data-i18n-placeholder="hotspot_users.form.password_placeholder" placeholder="e.g. 123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
|
<div class="space-y-1 col-span-1 md:col-span-2">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.profile">Profile</label>
|
||||||
|
<select name="profile" class="w-full" data-search="true">
|
||||||
|
<?php foreach($uniqueProfiles as $p): ?>
|
||||||
|
<option value="<?= htmlspecialchars($p) ?>"><?= htmlspecialchars($p) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.server">Server</label>
|
||||||
|
<select name="server" class="w-full">
|
||||||
|
<option value="all">all</option>
|
||||||
|
<?php
|
||||||
|
if (!empty($servers)):
|
||||||
|
foreach($servers as $s):
|
||||||
|
$sName = $s['name'] ?? '';
|
||||||
|
if ($sName === 'all' || empty($sName)) continue;
|
||||||
|
?>
|
||||||
|
<option value="<?= htmlspecialchars($sName) ?>"><?= htmlspecialchars($sName) ?></option>
|
||||||
|
<?php
|
||||||
|
endforeach;
|
||||||
|
endif;
|
||||||
|
?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.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="pl-10 w-full" placeholder="Optional note">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Limit -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.time_limit">Time Limit</label>
|
||||||
|
<div class="flex w-full">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">D</span>
|
||||||
|
<input type="number" name="timelimit_d" min="0" class="w-full pr-6 rounded-r-none border-r-0 text-center" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">H</span>
|
||||||
|
<input type="number" name="timelimit_h" min="0" max="23" class="w-full pr-6 rounded-none border-r-0 text-center" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">M</span>
|
||||||
|
<input type="number" name="timelimit_m" min="0" max="59" class="w-full pr-6 rounded-l-none text-center" placeholder="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Limit -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.data_limit">Data Limit</label>
|
||||||
|
<div class="flex relative w-full">
|
||||||
|
<div class="relative flex-grow z-0">
|
||||||
|
<span class="input-icon"><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" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="relative -ml-px w-20 z-0">
|
||||||
|
<select name="datalimit_unit" class="w-full rounded-l-none bg-accents-1 text-center font-medium">
|
||||||
|
<option value="MB" selected>MB</option>
|
||||||
|
<option value="GB">GB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Spacer for dropdowns -->
|
||||||
|
<div class="h-24"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips Column -->
|
||||||
|
<div class="hidden lg:block space-y-4 border-l border-white/10 pl-6">
|
||||||
|
<h3 class="text-sm font-bold text-foreground flex items-center gap-2">
|
||||||
|
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-400"></i>
|
||||||
|
<span data-i18n="hotspot_users.form.quick_tips">Quick Tips</span>
|
||||||
|
</h3>
|
||||||
|
<ul class="text-xs text-accents-5 space-y-3 list-disc pl-4">
|
||||||
|
<li data-i18n="hotspot_users.form.tip_profiles">
|
||||||
|
<strong>Profiles</strong> define the default speed limits, session timeout, and shared users policy.
|
||||||
|
</li>
|
||||||
|
<li data-i18n="hotspot_users.form.tip_time_limit">
|
||||||
|
<strong>Time Limit</strong> is the total accumulated uptime allowed for this user.
|
||||||
|
</li>
|
||||||
|
<li 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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
class TableManager {
|
class TableManager {
|
||||||
constructor(rows, itemsPerPage = 10) {
|
constructor(rows, itemsPerPage = 10) {
|
||||||
@@ -244,9 +400,7 @@ sort($uniqueComments);
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom Select Listener (Mutation Observer or custom event if we emitted one,
|
// Filters
|
||||||
// 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) => {
|
document.getElementById('filter-profile').addEventListener('change', (e) => {
|
||||||
this.filters.profile = e.target.value;
|
this.filters.profile = e.target.value;
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
@@ -259,10 +413,7 @@ sort($uniqueComments);
|
|||||||
this.update();
|
this.update();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-bind actions when external CustomSelect updates the select value
|
// Listen for language change
|
||||||
// CustomSelect triggers 'change' event on original select, so standard listener works!
|
|
||||||
|
|
||||||
// Listen for language change to update pagination text
|
|
||||||
window.addEventListener('languageChanged', () => {
|
window.addEventListener('languageChanged', () => {
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
@@ -272,10 +423,10 @@ sort($uniqueComments);
|
|||||||
// Apply Filters
|
// Apply Filters
|
||||||
this.filteredRows = this.allRows.filter(row => {
|
this.filteredRows = this.allRows.filter(row => {
|
||||||
const name = row.dataset.name || '';
|
const name = row.dataset.name || '';
|
||||||
const comment = (row.dataset.comment || '').toLowerCase(); // dataset comment value
|
const comment = (row.dataset.comment || '').toLowerCase();
|
||||||
const profile = row.dataset.profile || '';
|
const profile = row.dataset.profile || '';
|
||||||
|
|
||||||
// 1. Search (Name or Comment)
|
// 1. Search
|
||||||
if (this.filters.search) {
|
if (this.filters.search) {
|
||||||
const matchName = name.includes(this.filters.search);
|
const matchName = name.includes(this.filters.search);
|
||||||
const matchComment = comment.includes(this.filters.search);
|
const matchComment = comment.includes(this.filters.search);
|
||||||
@@ -285,7 +436,7 @@ sort($uniqueComments);
|
|||||||
// 2. Profile
|
// 2. Profile
|
||||||
if (this.filters.profile && profile !== this.filters.profile) return false;
|
if (this.filters.profile && profile !== this.filters.profile) return false;
|
||||||
|
|
||||||
// 3. Comment (Exact match for dropdown)
|
// 3. Comment
|
||||||
if (this.filters.comment && row.dataset.comment !== this.filters.comment) return false;
|
if (this.filters.comment && row.dataset.comment !== this.filters.comment) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -303,7 +454,7 @@ sort($uniqueComments);
|
|||||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||||
const end = Math.min(start + this.itemsPerPage, total);
|
const end = Math.min(start + this.itemsPerPage, total);
|
||||||
|
|
||||||
// Update Text (Use Translation)
|
// Update Text
|
||||||
if (window.i18n) {
|
if (window.i18n) {
|
||||||
const text = window.i18n.t('common.table.showing', {
|
const text = window.i18n.t('common.table.showing', {
|
||||||
start: total === 0 ? 0 : start + 1,
|
start: total === 0 ? 0 : start + 1,
|
||||||
@@ -312,9 +463,9 @@ sort($uniqueComments);
|
|||||||
});
|
});
|
||||||
document.getElementById('pagination-text').textContent = text;
|
document.getElementById('pagination-text').textContent = text;
|
||||||
} else {
|
} else {
|
||||||
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
|
// Fallback
|
||||||
this.elements.endIdx.textContent = end;
|
const el = document.getElementById('pagination-text');
|
||||||
this.elements.totalCount.textContent = total;
|
el.innerHTML = `Showing <span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span> to <span class="font-medium text-foreground">${end}</span> of <span class="font-medium text-foreground">${total}</span> users`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear & Append Rows
|
// Clear & Append Rows
|
||||||
@@ -332,118 +483,177 @@ sort($uniqueComments);
|
|||||||
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
|
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
|
// Re-init Icons
|
||||||
if (typeof lucide !== 'undefined') {
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Checkbox Logic (Select All should act on visible?)
|
// Reset "Select All"
|
||||||
// We usually reset "Select All" check when page changes
|
|
||||||
document.getElementById('select-all').checked = false;
|
document.getElementById('select-all').checked = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Modal Logic ---
|
||||||
|
function openUserModal(mode, btn = null) {
|
||||||
|
const template = document.getElementById('user-form-template').innerHTML;
|
||||||
|
|
||||||
|
let title = window.i18n ? window.i18n.t('hotspot_users.add_user') : 'Add User';
|
||||||
|
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
title = window.i18n ? window.i18n.t('hotspot_users.edit_user') : 'Edit User';
|
||||||
|
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
|
||||||
|
}
|
||||||
|
|
||||||
|
const preConfirmFn = () => {
|
||||||
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
const form = popup.querySelector('form');
|
||||||
|
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
|
||||||
|
form.action = "/<?= htmlspecialchars($session) ?>/hotspot/update";
|
||||||
|
|
||||||
|
// Populate Hidden ID
|
||||||
|
const idInput = form.querySelector('#form-id');
|
||||||
|
idInput.disabled = false;
|
||||||
|
idInput.value = row.dataset.id; // Ensure data-id is set on TR!
|
||||||
|
|
||||||
|
// Populate Fields (Assuming data attributes or simple values)
|
||||||
|
// NOTE: For full data (limits, etc), we might need to fetch OR put all in data attributes
|
||||||
|
// Let's rely on data attributes for speed, but need to add them to TR first
|
||||||
|
form.querySelector('[name="name"]').value = row.dataset.rawname || '';
|
||||||
|
form.querySelector('[name="password"]').value = row.dataset.password || '';
|
||||||
|
form.querySelector('[name="comment"]').value = row.dataset.commentRaw || '';
|
||||||
|
|
||||||
|
// Selects
|
||||||
|
const profileSel = form.querySelector('[name="profile"]');
|
||||||
|
if(profileSel) profileSel.value = row.dataset.profile;
|
||||||
|
|
||||||
|
const serverSel = form.querySelector('[name="server"]');
|
||||||
|
if(serverSel) serverSel.value = row.dataset.server || 'all';
|
||||||
|
|
||||||
|
// Limits (Parsing from data attributes)
|
||||||
|
// Time Limit
|
||||||
|
const tLimit = row.dataset.limitUptime || '';
|
||||||
|
// Simple regex parsing for 1d2h3m (Mikrotik format)
|
||||||
|
// This is complex to parse perfectly from string back to split fields without a helper
|
||||||
|
// For now, let's just leave 0 or try best effort if available
|
||||||
|
// Ideally, we pass split values in data attributes from PHP
|
||||||
|
if (row.dataset.timeD) form.querySelector('[name="timelimit_d"]').value = row.dataset.timeD;
|
||||||
|
if (row.dataset.timeH) form.querySelector('[name="timelimit_h"]').value = row.dataset.timeH;
|
||||||
|
if (row.dataset.timeM) form.querySelector('[name="timelimit_m"]').value = row.dataset.timeM;
|
||||||
|
|
||||||
|
// Data Limit
|
||||||
|
if (row.dataset.limitBytesTotal) {
|
||||||
|
const bytes = parseInt(row.dataset.limitBytesTotal);
|
||||||
|
if (bytes > 0) {
|
||||||
|
if (bytes >= 1073741824) { // GB
|
||||||
|
form.querySelector('[name="datalimit_val"]').value = (bytes / 1073741824).toFixed(0); // integer prefer
|
||||||
|
form.querySelector('[name="datalimit_unit"]').value = 'GB';
|
||||||
|
} else { // MB
|
||||||
|
form.querySelector('[name="datalimit_val"]').value = (bytes / 1048576).toFixed(0);
|
||||||
|
form.querySelector('[name="datalimit_unit"]').value = 'MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Init Custom Selects
|
// Init Checkboxes & Table methods
|
||||||
|
const selectAll = document.getElementById('select-all');
|
||||||
|
const toolbar = document.getElementById('batch-toolbar');
|
||||||
|
const countSpan = document.getElementById('selected-count');
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
|
||||||
|
// Init Custom Selects on Filter Bar
|
||||||
if (typeof CustomSelect !== 'undefined') {
|
if (typeof CustomSelect !== 'undefined') {
|
||||||
document.querySelectorAll('.custom-select').forEach(select => {
|
document.querySelectorAll('.custom-select.form-filter').forEach(s => new CustomSelect(s));
|
||||||
new CustomSelect(select);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init Table
|
// Init Table
|
||||||
const rows = document.querySelectorAll('.table-row-item');
|
const rows = document.querySelectorAll('.table-row-item');
|
||||||
const manager = new TableManager(rows, 10);
|
const manager = new TableManager(rows, 10);
|
||||||
|
|
||||||
// --- Toolbar Logic (Copied/Adapted) ---
|
// Toolbar Logic
|
||||||
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() {
|
function updateToolbar() {
|
||||||
const checked = document.querySelectorAll('.user-checkbox:checked');
|
const checked = document.querySelectorAll('.user-checkbox:checked');
|
||||||
countSpan.textContent = checked.length;
|
countSpan.textContent = checked.length;
|
||||||
|
if (checked.length > 0) toolbar.classList.remove('translate-y-20', 'opacity-0');
|
||||||
if (checked.length > 0) {
|
else toolbar.classList.add('translate-y-20', 'opacity-0');
|
||||||
toolbar.classList.remove('translate-y-20', 'opacity-0');
|
|
||||||
} else {
|
|
||||||
toolbar.classList.add('translate-y-20', 'opacity-0');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(selectAll) {
|
||||||
selectAll.addEventListener('change', (e) => {
|
selectAll.addEventListener('change', (e) => {
|
||||||
const isChecked = e.target.checked;
|
const isChecked = e.target.checked;
|
||||||
// Only select visible rows on current page
|
// Only select visible rows
|
||||||
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
|
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
|
||||||
visibleCheckboxes.forEach(cb => cb.checked = isChecked);
|
visibleCheckboxes.forEach(cb => cb.checked = isChecked);
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Event Delegation for dynamic rows
|
if(tableBody) {
|
||||||
tableBody.addEventListener('change', (e) => {
|
tableBody.addEventListener('change', (e) => {
|
||||||
if (e.target.classList.contains('user-checkbox')) {
|
if (e.target.classList.contains('user-checkbox')) {
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
if (!e.target.checked) selectAll.checked = false;
|
if (!e.target.checked) selectAll.checked = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
function printUser(id) {
|
function printUser(id) {
|
||||||
const width = 400;
|
const width = 400; const height = 600;
|
||||||
const height = 600;
|
|
||||||
const left = (window.innerWidth - width) / 2;
|
const left = (window.innerWidth - width) / 2;
|
||||||
const top = (window.innerHeight - height) / 2;
|
const top = (window.innerHeight - height) / 2;
|
||||||
const session = '<?= htmlspecialchars($session) ?>';
|
const session = '<?= htmlspecialchars($session) ?>';
|
||||||
const url = `/${session}/hotspot/print/${encodeURIComponent(id)}`;
|
window.open(`/${session}/hotspot/print/${encodeURIComponent(id)}`, `PrintUser`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
||||||
window.open(url, `PrintUser`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printSelected() {
|
function printSelected() {
|
||||||
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
|
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.");
|
if (selected.length === 0) return Mivo.alert('info', 'No selection', window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "No users selected.");
|
||||||
|
|
||||||
const width = 800;
|
const width = 800; const height = 600;
|
||||||
const height = 600;
|
|
||||||
const left = (window.innerWidth - width) / 2;
|
const left = (window.innerWidth - width) / 2;
|
||||||
const top = (window.innerHeight - height) / 2;
|
const top = (window.innerHeight - height) / 2;
|
||||||
const session = '<?= htmlspecialchars($session) ?>';
|
const session = '<?= htmlspecialchars($session) ?>';
|
||||||
const ids = selected.map(id => encodeURIComponent(id)).join(',');
|
const ids = selected.map(id => encodeURIComponent(id)).join(',');
|
||||||
const url = `/${session}/hotspot/print-batch?ids=${ids}`;
|
window.open(`/${session}/hotspot/print-batch?ids=${ids}`, `PrintBatch`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
||||||
window.open(url, `PrintBatch`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelected() {
|
function deleteSelected() {
|
||||||
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
|
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.");
|
if (selected.length === 0) return Mivo.alert('info', 'No selection', 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 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?`;
|
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 => {
|
Mivo.confirm(title, msg, window.i18n.t('common.delete'), window.i18n.t('common.cancel')).then(res => {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
|
|
||||||
// Create a form to submit
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/<?= htmlspecialchars($session) ?>/hotspot/delete'; // Re-uses the delete endpoint
|
form.action = '/<?= htmlspecialchars($session) ?>/hotspot/delete';
|
||||||
|
const sInput = document.createElement('input');
|
||||||
const sessionInput = document.createElement('input');
|
sInput.type = 'hidden'; sInput.name = 'session'; sInput.value = '<?= htmlspecialchars($session) ?>';
|
||||||
sessionInput.type = 'hidden';
|
form.appendChild(sInput);
|
||||||
sessionInput.name = 'session';
|
|
||||||
sessionInput.value = '<?= htmlspecialchars($session) ?>';
|
|
||||||
form.appendChild(sessionInput);
|
|
||||||
|
|
||||||
const idInput = document.createElement('input');
|
const idInput = document.createElement('input');
|
||||||
idInput.type = 'hidden';
|
idInput.type = 'hidden'; idInput.name = 'id'; idInput.value = selected.join(',');
|
||||||
idInput.name = 'id';
|
|
||||||
idInput.value = selected.join(','); // Comma separated IDs
|
|
||||||
form.appendChild(idInput);
|
form.appendChild(idInput);
|
||||||
|
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
form.submit();
|
||||||
document.body.removeChild(form);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,9 +6,26 @@
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
</div> <!-- /.container (Navbar Global) -->
|
</div> <!-- /.container (Navbar Global) -->
|
||||||
|
|
||||||
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200">
|
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200 py-8 text-center space-y-4">
|
||||||
<div class="max-w-7xl mx-auto px-4 py-6 text-center text-sm text-accents-5">
|
<!-- Links Row -->
|
||||||
<p><?= \App\Config\SiteConfig::getFooter() ?></p>
|
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
|
||||||
|
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||||
|
<span>Docs</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||||
|
<span>Community</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/dyzulk/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="github" class="w-4 h-4"></i>
|
||||||
|
<span>Repo</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copyright Row -->
|
||||||
|
<div class="text-xs text-accents-4 opacity-50">
|
||||||
|
<?= \App\Config\SiteConfig::getFooter() ?>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -129,20 +146,67 @@
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Global Dropdown & Sidebar Logic
|
// Global Dropdown & Sidebar Logic
|
||||||
|
let menuTimeout;
|
||||||
|
|
||||||
function toggleMenu(menuId, button) {
|
function toggleMenu(menuId, button) {
|
||||||
|
if (menuTimeout) clearTimeout(menuTimeout);
|
||||||
|
|
||||||
const menu = document.getElementById(menuId);
|
const menu = document.getElementById(menuId);
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
// Handle Dropdowns (IDs start with 'lang-' or 'session-')
|
// Handle Dropdowns (IDs start with 'lang-', 'session-', or is 'notification-')
|
||||||
if (menuId.startsWith('lang-') || menuId === 'session-dropdown') {
|
if (menuId.startsWith('lang-') || menuId === 'session-dropdown' || menuId === 'notification-dropdown') {
|
||||||
if (menu.classList.contains('invisible')) {
|
const sidebarHeader = document.getElementById('sidebar-header');
|
||||||
|
const isOpening = menu.classList.contains('invisible');
|
||||||
|
|
||||||
|
if (isOpening) {
|
||||||
|
// Smart Positioning Logic
|
||||||
|
// 1. Reset to base state (remove specific overrides to measure natural/preferred state)
|
||||||
|
// But we want to preserve 'absolute' etc. The HTML has 'left-1/2 -translate-x-1/2' by default for sidebar.
|
||||||
|
// We'll calculate based on button rect and assumed menu width (w-48 = 12rem = 192px approx, or measure)
|
||||||
|
|
||||||
|
const btnRect = button.getBoundingClientRect();
|
||||||
|
const menuWidth = 192; // Approx w-48 standard. Better to measure if possible, but invisible elements have width.
|
||||||
|
// Actually, if we make it visible but opacity-0 first, we can measure.
|
||||||
|
// But simpler math:
|
||||||
|
const centerX = btnRect.left + (btnRect.width / 2);
|
||||||
|
const leftEdge = centerX - (menuWidth / 2);
|
||||||
|
const rightEdge = centerX + (menuWidth / 2);
|
||||||
|
|
||||||
|
// Remove conflicting positioning classes first to ensure a clean slate if we need to override
|
||||||
|
menu.classList.remove('left-0', 'right-0', 'left-1/2', '-translate-x-1/2', 'origin-top-left', 'origin-top-right', 'origin-top', 'left-3');
|
||||||
|
|
||||||
|
// Decision Tree
|
||||||
|
if (leftEdge < 10) {
|
||||||
|
// overflow left -> Align Left
|
||||||
|
menu.classList.add('left-0', 'origin-top-left');
|
||||||
|
} else if (rightEdge > window.innerWidth - 10) {
|
||||||
|
// overflow right -> Align Right
|
||||||
|
menu.classList.add('right-0', 'origin-top-right');
|
||||||
|
} else {
|
||||||
|
// Safe to Center
|
||||||
|
menu.classList.add('left-1/2', '-translate-x-1/2', 'origin-top');
|
||||||
|
}
|
||||||
|
|
||||||
// Open
|
// Open
|
||||||
menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||||
menu.classList.add('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
menu.classList.add('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||||
|
|
||||||
|
// Special Case: Sidebar Lang Dropdown needs overflow visible on header
|
||||||
|
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||||
|
sidebarHeader.classList.remove('overflow-hidden');
|
||||||
|
sidebarHeader.classList.add('overflow-visible');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Close
|
// Close
|
||||||
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||||
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||||
|
|
||||||
|
// Revert Overflow
|
||||||
|
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||||
|
sidebarHeader.classList.add('overflow-hidden');
|
||||||
|
sidebarHeader.classList.remove('overflow-visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,20 +239,22 @@
|
|||||||
|
|
||||||
// Close dropdowns when clicking outside
|
// Close dropdowns when clicking outside
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const dropdowns = document.querySelectorAll('[id^="lang-dropdown"], #session-dropdown');
|
const dropdowns = document.querySelectorAll('[id^="lang-dropdown"], #session-dropdown, #notification-dropdown');
|
||||||
dropdowns.forEach(dropdown => {
|
dropdowns.forEach(dropdown => {
|
||||||
if (!dropdown.classList.contains('invisible')) {
|
const sidebarHeader = document.getElementById('sidebar-header');
|
||||||
// Find the trigger button (previous sibling usually)
|
|
||||||
// Robust way: check if click is inside dropdown OR inside the button that toggles it
|
|
||||||
// Since button calls toggleMenu, we just need to ignore clicks inside dropdown and button?
|
|
||||||
// Actually, simpler: just check if click is OUTSIDE dropdown.
|
|
||||||
// But if click is on button, let button handler toggle it (don't double toggle).
|
|
||||||
|
|
||||||
|
if (!dropdown.classList.contains('invisible')) {
|
||||||
const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`);
|
const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`);
|
||||||
|
|
||||||
if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) {
|
if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) {
|
||||||
dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||||
dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||||
|
|
||||||
|
// Revert Sidebar Overflow if needed
|
||||||
|
if (dropdown.id === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||||
|
sidebarHeader.classList.add('overflow-hidden');
|
||||||
|
sidebarHeader.classList.remove('overflow-visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -209,18 +275,36 @@
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
Mivo.toast('success', title.replace('?', ''), 'The command has been sent to the router.');
|
Mivo.toast('success', title.replace('?', ''), 'The command has been sent to the router.');
|
||||||
} else {
|
} else {
|
||||||
Swal.fire({
|
Mivo.alert('error', 'Action Failed', data.error || 'Unknown error occurred.');
|
||||||
icon: 'error',
|
|
||||||
title: 'Action Failed',
|
|
||||||
text: data.error || 'Unknown error occurred.',
|
|
||||||
background: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
backdrop: 'rgba(0,0,0,0.1)'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Mivo.toast('error', 'Connection Error', 'Failed to reach the server.');
|
Mivo.toast('error', 'Connection Error', 'Failed to reach the server.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-Close Helper with Debounce
|
||||||
|
function closeMenu(menuId) {
|
||||||
|
if (menuTimeout) clearTimeout(menuTimeout);
|
||||||
|
|
||||||
|
// Notification dropdown is more "sticky" (800ms vs 300ms elsewhere)
|
||||||
|
const delay = (menuId === 'notification-dropdown') ? 800 : 300;
|
||||||
|
|
||||||
|
menuTimeout = setTimeout(() => {
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
const sidebarHeader = document.getElementById('sidebar-header');
|
||||||
|
|
||||||
|
if (menu && !menu.classList.contains('invisible')) {
|
||||||
|
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||||
|
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||||
|
|
||||||
|
// Revert Overflow if needed
|
||||||
|
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||||
|
sidebarHeader.classList.add('overflow-hidden');
|
||||||
|
sidebarHeader.classList.remove('overflow-visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300); // 300ms delay to prevent accidental closure
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
<footer class="mt-auto py-6 text-center text-xs text-accents-5 opacity-60">
|
<footer class="mt-auto py-8 text-center space-y-4">
|
||||||
|
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
|
||||||
|
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||||
|
<span>Docs</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||||
|
<span>Community</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/dyzulk/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="github" class="w-4 h-4"></i>
|
||||||
|
<span>Repo</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copyright Row -->
|
||||||
|
<div class="text-xs text-accents-4 opacity-50">
|
||||||
<?= \App\Config\SiteConfig::getFooter() ?>
|
<?= \App\Config\SiteConfig::getFooter() ?>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -38,34 +56,17 @@
|
|||||||
|
|
||||||
// Use Custom Toasts for most notifications (Success, Info, Error)
|
// Use Custom Toasts for most notifications (Success, Info, Error)
|
||||||
// Only use Modal (Swal) for specific heavy warnings or questions if needed
|
// Only use Modal (Swal) for specific heavy warnings or questions if needed
|
||||||
|
// Use Toasts for standard notifications
|
||||||
if (['success', 'info', 'error', 'warning'].includes(type)) {
|
if (['success', 'info', 'error', 'warning'].includes(type)) {
|
||||||
// Assuming Mivo.toast is available globally or via another script check
|
|
||||||
if (window.Mivo && window.Mivo.toast) {
|
if (window.Mivo && window.Mivo.toast) {
|
||||||
Mivo.toast(type, title, message);
|
Mivo.toast(type, title, message);
|
||||||
} else {
|
|
||||||
console.log('Toast:', title, message);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use Swal for 'question' or fallback
|
// For questions or other types, use Modal Alert
|
||||||
if (typeof Swal !== 'undefined') {
|
if (window.Mivo && window.Mivo.alert) {
|
||||||
Swal.fire({
|
Mivo.alert(type || 'info', title, message);
|
||||||
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`,
|
} else if (typeof Swal !== 'undefined') {
|
||||||
title: title,
|
Swal.fire(title, message, type);
|
||||||
text: message,
|
|
||||||
confirmButtonText: 'OK',
|
|
||||||
customClass: {
|
|
||||||
popup: 'swal2-premium-card',
|
|
||||||
confirmButton: 'btn btn-primary',
|
|
||||||
cancelButton: 'btn btn-secondary',
|
|
||||||
},
|
|
||||||
buttonsStyling: false,
|
|
||||||
heightAuto: false,
|
|
||||||
didOpen: () => {
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert(`${title}\n${message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,12 +54,16 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
|
|||||||
</script>
|
</script>
|
||||||
<script src="/assets/js/jquery.min.js"></script>
|
<script src="/assets/js/jquery.min.js"></script>
|
||||||
<script src="/assets/js/lucide.min.js"></script>
|
<script src="/assets/js/lucide.min.js"></script>
|
||||||
<script src="/assets/js/custom-select.js" defer></script>
|
<script>
|
||||||
<script src="/assets/js/datatable.js" defer></script>
|
window.currentVersion = '<?= \App\Config\SiteConfig::APP_VERSION ?>';
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/mivo.js" defer></script>
|
||||||
|
<script src="/assets/js/modules/updater.js" defer></script>
|
||||||
|
<script src="/assets/js/components/select.js" defer></script>
|
||||||
|
<script src="/assets/js/components/datatable.js" defer></script>
|
||||||
<script src="/assets/js/sweetalert2.all.min.js" defer></script>
|
<script src="/assets/js/sweetalert2.all.min.js" defer></script>
|
||||||
<script src="/assets/js/alert-helper.js" defer></script>
|
<script src="/assets/js/modules/alert.js" defer></script>
|
||||||
<script src="/assets/js/i18n.js" defer></script>
|
<script src="/assets/js/modules/i18n.js" defer></script>
|
||||||
<script src="/assets/js/i18n.js" defer></script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Global Form Input Style - Matches Vercel Design System */
|
/* Global Form Input Style - Matches Vercel Design System */
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
<link rel="stylesheet" href="/assets/css/styles.css">
|
<link rel="stylesheet" href="/assets/css/styles.css">
|
||||||
<script src="/assets/js/lucide.min.js"></script>
|
<script src="/assets/js/lucide.min.js"></script>
|
||||||
<script src="/assets/js/sweetalert2.all.min.js" defer></script>
|
<script src="/assets/js/sweetalert2.all.min.js" defer></script>
|
||||||
<script src="/assets/js/alert-helper.js" defer></script>
|
<script src="/assets/js/mivo.js" defer></script>
|
||||||
<script src="/assets/js/i18n.js" defer></script>
|
<script src="/assets/js/modules/alert.js" defer></script>
|
||||||
|
<script src="/assets/js/modules/i18n.js" defer></script>
|
||||||
<style>
|
<style>
|
||||||
/* Custom Keyframes */
|
/* Custom Keyframes */
|
||||||
@keyframes fade-in-up {
|
@keyframes fade-in-up {
|
||||||
@@ -39,30 +40,126 @@
|
|||||||
<div class="absolute top-[30%] -right-[15%] w-[60vw] h-[60vw] rounded-full bg-purple-500/20 dark:bg-purple-500/5 blur-[100px] animate-pulse" style="animation-duration: 6s; animation-delay: 1s;"></div>
|
<div class="absolute top-[30%] -right-[15%] w-[60vw] h-[60vw] rounded-full bg-purple-500/20 dark:bg-purple-500/5 blur-[100px] animate-pulse" style="animation-duration: 6s; animation-delay: 1s;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Theme Toggle (Bottom Right) -->
|
<!-- Top Right Controls (Pill Theme Toggle & Lang Switcher) -->
|
||||||
<button id="theme-toggle" class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-background border border-accents-2 shadow-lg text-accents-5 hover:text-foreground hover:border-foreground transition-all duration-300 group" style="position: fixed; bottom: 1.5rem; right: 1.5rem;">
|
<div class="fixed top-4 right-4 z-50 flex items-center space-x-3">
|
||||||
<i data-lucide="moon" class="w-5 h-5 block dark:hidden group-hover:scale-110 transition-transform"></i>
|
<!-- Language Switcher -->
|
||||||
<i data-lucide="sun" class="w-5 h-5 hidden dark:block group-hover:scale-110 transition-transform"></i>
|
<div class="relative group">
|
||||||
|
<button onclick="toggleMenu('lang-dropdown-public', this)" class="h-9 px-3 rounded-full bg-background/50 backdrop-blur-md border border-accents-2 hover:border-foreground/20 text-accents-5 hover:text-foreground transition-all flex items-center shadow-sm">
|
||||||
|
<i data-lucide="globe" class="w-4 h-4 mr-2"></i>
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider" id="current-lang-label">EN</span>
|
||||||
|
<i data-lucide="chevron-down" class="w-3 h-3 ml-2 opacity-50"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<div id="lang-dropdown-public" class="hidden absolute right-0 mt-2 w-32 bg-background/95 backdrop-blur-2xl border border-white/10 rounded-xl shadow-2xl py-1 z-50 transform origin-top-right transition-all duration-200" onmouseleave="closeMenu('lang-dropdown-public')">
|
||||||
|
<button onclick="changeLanguage('en')" class="w-full text-left px-4 py-2 text-xs font-medium text-accents-5 hover:text-foreground hover:bg-white/5 flex items-center group">
|
||||||
|
<span class="mr-2 text-lg">🇺🇸</span> English
|
||||||
|
</button>
|
||||||
|
<button onclick="changeLanguage('id')" class="w-full text-left px-4 py-2 text-xs font-medium text-accents-5 hover:text-foreground hover:bg-white/5 flex items-center group">
|
||||||
|
<span class="mr-2 text-lg">🇮🇩</span> Indonesia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme Toggle Pill -->
|
||||||
|
<div class="h-9 p-1 bg-accents-2/50 backdrop-blur-md border border-accents-2 rounded-full flex items-center relative" id="theme-pill">
|
||||||
|
<!-- Gliding Background -->
|
||||||
|
<div class="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-background rounded-full shadow-sm transition-all duration-300 ease-spring" id="theme-glider" style="left: 4px;"></div>
|
||||||
|
|
||||||
|
<button onclick="setTheme('light')" class="relative z-10 w-8 h-full flex items-center justify-center text-accents-5 hover:text-foreground transition-colors rounded-full" id="btn-light">
|
||||||
|
<i data-lucide="sun" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="setTheme('dark')" class="relative z-10 w-8 h-full flex items-center justify-center text-accents-5 hover:text-foreground transition-colors rounded-full" id="btn-dark">
|
||||||
|
<i data-lucide="moon" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Toggle Menu Helper (Reuse or define for public if main footer not loaded)
|
||||||
|
// Public footer includes site config footer, but maybe not main JS.
|
||||||
|
// Let's define simple toggle for public page to be safe and independent.
|
||||||
|
function toggleMenu(id, btn) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const isHidden = el.classList.contains('hidden');
|
||||||
|
|
||||||
|
// Close others if needed (optional)
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
el.classList.remove('hidden', 'scale-95', 'opacity-0');
|
||||||
|
el.classList.add('scale-100', 'opacity-100');
|
||||||
|
} else {
|
||||||
|
closeMenu(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && !el.classList.contains('hidden')) {
|
||||||
|
el.classList.remove('scale-100', 'opacity-100');
|
||||||
|
el.classList.add('hidden', 'scale-95', 'opacity-0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
// Theme Toggle Logic
|
// Theme Logic
|
||||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
const glider = document.getElementById('theme-glider');
|
||||||
|
const btnLight = document.getElementById('btn-light');
|
||||||
|
const btnDark = document.getElementById('btn-dark');
|
||||||
const htmlElement = document.documentElement;
|
const htmlElement = document.documentElement;
|
||||||
|
|
||||||
if(themeToggleBtn){
|
window.setTheme = (theme) => {
|
||||||
themeToggleBtn.addEventListener('click', () => {
|
if (theme === 'dark') {
|
||||||
if (htmlElement.classList.contains('dark')) {
|
|
||||||
htmlElement.classList.remove('dark');
|
|
||||||
localStorage.theme = 'light';
|
|
||||||
} else {
|
|
||||||
htmlElement.classList.add('dark');
|
htmlElement.classList.add('dark');
|
||||||
localStorage.theme = 'dark';
|
localStorage.theme = 'dark';
|
||||||
|
glider.style.transform = 'translateX(100%)';
|
||||||
|
// adjustment: logic depends on width.
|
||||||
|
// container is w-8+w-8+padding.
|
||||||
|
// simplest is just left/right toggle classes or transform.
|
||||||
|
// using transform translateX(100%) works if width is exactly 50% parent minus padding.
|
||||||
|
// padding is 1 (4px). buttons are w-8 (32px).
|
||||||
|
// let's use explicit left style or class-based positioning if easier.
|
||||||
|
// Tailwind 'translate-x-full' moves 100% of own width.
|
||||||
|
// If glider is w-[calc(50%-4px)], moving 100% of itself is almost correct but includes gap.
|
||||||
|
// Let's rely on simple pixel math or percentage relative to parent?
|
||||||
|
// actually `left: 4px` vs `left: calc(100% - width - 4px)`.
|
||||||
|
glider.style.left = 'auto';
|
||||||
|
glider.style.right = '4px';
|
||||||
|
} else {
|
||||||
|
htmlElement.classList.remove('dark');
|
||||||
|
localStorage.theme = 'light';
|
||||||
|
glider.style.right = 'auto';
|
||||||
|
glider.style.left = '4px';
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Update Active Colors
|
||||||
|
if (theme === 'dark') {
|
||||||
|
btnDark.classList.add('text-foreground');
|
||||||
|
btnLight.classList.remove('text-foreground');
|
||||||
|
} else {
|
||||||
|
btnLight.classList.add('text-foreground');
|
||||||
|
btnDark.classList.remove('text-foreground');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Init
|
||||||
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
setTheme('dark');
|
||||||
|
} else {
|
||||||
|
setTheme('light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language Init (Mock)
|
||||||
|
const currentLang = localStorage.getItem('mivo_lang') || 'en';
|
||||||
|
const langLabel = document.getElementById('current-lang-label');
|
||||||
|
if(langLabel) langLabel.innerText = currentLang.toUpperCase();
|
||||||
|
|
||||||
|
window.changeLanguage = (lang) => {
|
||||||
|
localStorage.setItem('mivo_lang', lang);
|
||||||
|
// Reload or use i18n module to reload
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,28 +15,46 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Navigation Links (Hidden on Mobile) -->
|
<!-- Desktop Navigation Links (Hidden on Mobile) -->
|
||||||
|
<?php if(isset($_SESSION['user_id'])): ?>
|
||||||
<div class="hidden md:flex items-center gap-6 text-sm font-medium">
|
<div class="hidden md:flex items-center gap-6 text-sm font-medium">
|
||||||
<a href="/" class="relative py-1 <?= ($uri == '/' || $uri == '/home') ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Home</a>
|
<a href="/" class="relative py-1 <?= ($uri == '/' || $uri == '/home') ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Home</a>
|
||||||
<a href="/settings" class="relative py-1 <?= (strpos($uri, '/settings') === 0) ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Settings</a>
|
<a href="/settings" class="relative py-1 <?= (strpos($uri, '/settings') === 0) ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side controls -->
|
<!-- Right side controls -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Desktop Control Pill (Hidden on Mobile) -->
|
<!-- Desktop Control Pill (Hidden on Mobile) -->
|
||||||
<div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform">
|
<div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform">
|
||||||
<!-- Language Switcher -->
|
<!-- Notification Bell -->
|
||||||
<div class="relative group">
|
<div class="relative group" onmouseleave="closeMenu('notification-dropdown')">
|
||||||
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-nav', this)" title="Change Language">
|
<button id="notification-bell" type="button" class="pill-lang-btn relative" onclick="toggleMenu('notification-dropdown', this)" title="Notifications">
|
||||||
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
|
<i data-lucide="bell" class="w-4 h-4"></i>
|
||||||
|
<span id="update-badge" class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full hidden animate-pulse"></span>
|
||||||
</button>
|
</button>
|
||||||
<div id="lang-dropdown-nav" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50">
|
<div id="notification-dropdown" class="absolute right-0 top-full mt-3 w-64 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge">
|
||||||
|
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="notifications.title">Notifications</div>
|
||||||
|
<div id="notification-content" class="p-4 text-sm text-accents-5 text-center" data-i18n="notifications.empty">
|
||||||
|
No new notifications
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pill-divider"></div>
|
||||||
|
|
||||||
|
<!-- Language Switcher -->
|
||||||
|
<div class="relative group" onmouseleave="closeMenu('lang-dropdown-desktop')">
|
||||||
|
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-desktop', this)" title="Change Language">
|
||||||
|
<i data-lucide="languages" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<div id="lang-dropdown-desktop" class="absolute right-0 top-full mt-3 w-48 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge">
|
||||||
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
||||||
<?php
|
<?php
|
||||||
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
||||||
foreach ($languages as $lang):
|
foreach ($languages as $lang):
|
||||||
?>
|
?>
|
||||||
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang">
|
<button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang">
|
||||||
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
|
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
|
||||||
<span><?= $lang['name'] ?></span>
|
<span><?= $lang['name'] ?></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -44,8 +62,6 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pill-divider"></div>
|
|
||||||
|
|
||||||
<!-- Theme Toggle (Segmented) -->
|
<!-- Theme Toggle (Segmented) -->
|
||||||
<div class="segmented-switch theme-toggle" title="Toggle Theme">
|
<div class="segmented-switch theme-toggle" title="Toggle Theme">
|
||||||
<div class="segmented-switch-slider"></div>
|
<div class="segmented-switch-slider"></div>
|
||||||
@@ -88,6 +104,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
<div id="mobile-navbar-menu" class="md:hidden border-t border-accents-2 bg-background/95 backdrop-blur-xl transition-all duration-300 ease-in-out max-h-0 opacity-0 invisible overflow-hidden">
|
<div id="mobile-navbar-menu" class="md:hidden border-t border-accents-2 bg-background/95 backdrop-blur-xl transition-all duration-300 ease-in-out max-h-0 opacity-0 invisible overflow-hidden">
|
||||||
<div class="px-4 pt-4 pb-6 space-y-4">
|
<div class="px-4 pt-4 pb-6 space-y-4">
|
||||||
<!-- Nav Links -->
|
<!-- Nav Links -->
|
||||||
|
<?php if(isset($_SESSION['user_id'])): ?>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= ($uri == '/' || $uri == '/home') ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>">
|
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= ($uri == '/' || $uri == '/home') ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>">
|
||||||
<i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
|
<i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
|
||||||
@@ -98,6 +115,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Mobile Controls Overlay -->
|
<!-- Mobile Controls Overlay -->
|
||||||
<div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4">
|
<div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
$isDashboard = strpos($uri, '/dashboard') !== false;
|
$isDashboard = strpos($uri, '/dashboard') !== false;
|
||||||
$isGenerate = strpos($uri, '/hotspot/generate') !== false;
|
$isGenerate = strpos($uri, '/hotspot/generate') !== false;
|
||||||
$isTemplates = strpos($uri, '/settings/templates') !== false;
|
$isTemplates = strpos($uri, '/settings/voucher-templates') !== false;
|
||||||
$isSettings = ($uri === '/settings' || strpos($uri, '/settings/') !== false) && !$isTemplates;
|
$isSettings = ($uri === '/settings' || strpos($uri, '/settings/') !== false) && !$isTemplates;
|
||||||
|
|
||||||
// Hotspot Group Active Check
|
// Hotspot Group Active Check
|
||||||
@@ -106,7 +106,7 @@ $getInitials = function($name) {
|
|||||||
<aside id="sidebar" class="w-64 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-[40px] fixed md:static inset-y-0 left-0 z-40 transform -translate-x-full md:translate-x-0 transition-transform duration-200 flex flex-col h-full">
|
<aside id="sidebar" class="w-64 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-[40px] fixed md:static inset-y-0 left-0 z-40 transform -translate-x-full md:translate-x-0 transition-transform duration-200 flex flex-col h-full">
|
||||||
<!-- Sidebar Header -->
|
<!-- Sidebar Header -->
|
||||||
<!-- Sidebar Header -->
|
<!-- Sidebar Header -->
|
||||||
<div class="group flex flex-col items-center py-5 border-b border-accents-2 flex-shrink-0 relative cursor-default overflow-hidden">
|
<div id="sidebar-header" class="group flex flex-col items-center py-5 border-b border-accents-2 flex-shrink-0 relative cursor-default overflow-hidden">
|
||||||
<div class="relative w-full h-10 flex items-center justify-center">
|
<div class="relative w-full h-10 flex items-center justify-center">
|
||||||
<!-- Brand (Slides out to the Left) -->
|
<!-- Brand (Slides out to the Left) -->
|
||||||
<div class="flex items-center gap-2 font-bold text-2xl tracking-tighter transition-all duration-500 ease-in-out group-hover:-translate-x-full group-hover:opacity-0">
|
<div class="flex items-center gap-2 font-bold text-2xl tracking-tighter transition-all duration-500 ease-in-out group-hover:-translate-x-full group-hover:opacity-0">
|
||||||
@@ -119,17 +119,19 @@ $getInitials = function($name) {
|
|||||||
<div class="absolute inset-0 hidden md:flex items-center justify-center transition-all duration-500 ease-in-out translate-x-full opacity-0 group-hover:translate-x-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto z-10">
|
<div class="absolute inset-0 hidden md:flex items-center justify-center transition-all duration-500 ease-in-out translate-x-full opacity-0 group-hover:translate-x-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto z-10">
|
||||||
<div class="control-pill scale-90 transition-transform hover:scale-100 shadow-lg bg-white/10 dark:bg-black/20 backdrop-blur-md">
|
<div class="control-pill scale-90 transition-transform hover:scale-100 shadow-lg bg-white/10 dark:bg-black/20 backdrop-blur-md">
|
||||||
<!-- Language Switcher -->
|
<!-- Language Switcher -->
|
||||||
<div class="relative group/lang">
|
<!-- Language Switcher (Mivo Component) -->
|
||||||
|
<!-- Language Switcher -->
|
||||||
|
<div class="relative group/lang" onmouseleave="closeMenu('lang-dropdown-sidebar')">
|
||||||
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-sidebar', this)" title="Change Language">
|
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-sidebar', this)" title="Change Language">
|
||||||
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
|
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
|
||||||
</button>
|
</button>
|
||||||
<div id="lang-dropdown-sidebar" class="absolute left-1/2 -translate-x-1/2 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none z-50">
|
<div id="lang-dropdown-sidebar" class="absolute left-1/2 -translate-x-1/2 top-full mt-3 w-48 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge" onmouseenter="if(typeof menuTimeout !== 'undefined') clearTimeout(menuTimeout)">
|
||||||
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
||||||
<?php
|
<?php
|
||||||
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
||||||
foreach ($languages as $lang):
|
foreach ($languages as $lang):
|
||||||
?>
|
?>
|
||||||
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang-item">
|
<button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang-item">
|
||||||
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang-item:scale-110"></span>
|
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang-item:scale-110"></span>
|
||||||
<span><?= $lang['name'] ?></span>
|
<span><?= $lang['name'] ?></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -163,7 +165,7 @@ $getInitials = function($name) {
|
|||||||
<div class="flex-1 overflow-y-auto" style="direction: rtl;">
|
<div class="flex-1 overflow-y-auto" style="direction: rtl;">
|
||||||
<div class="py-4 px-3 space-y-1" style="direction: ltr;">
|
<div class="py-4 px-3 space-y-1" style="direction: ltr;">
|
||||||
<!-- Session Switcher -->
|
<!-- Session Switcher -->
|
||||||
<div class="px-3 mb-6 relative">
|
<div class="px-3 mb-6 relative" onmouseleave="closeMenu('session-dropdown')">
|
||||||
<button type="button" class="w-full group grid grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-2.5 rounded-xl bg-white/50 dark:bg-white/5 border border-accents-2 dark:border-white/10 hover:bg-white/80 dark:hover:bg-white/10 transition-all decoration-0 overflow-hidden shadow-sm" onclick="toggleMenu('session-dropdown', this)">
|
<button type="button" class="w-full group grid grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-2.5 rounded-xl bg-white/50 dark:bg-white/5 border border-accents-2 dark:border-white/10 hover:bg-white/80 dark:hover:bg-white/10 transition-all decoration-0 overflow-hidden shadow-sm" onclick="toggleMenu('session-dropdown', this)">
|
||||||
<!-- Initials -->
|
<!-- Initials -->
|
||||||
<div class="h-8 w-8 rounded-lg bg-accents-2/50 group-hover:bg-accents-2 flex items-center justify-center text-xs font-bold text-accents-6 group-hover:text-foreground transition-colors flex-shrink-0">
|
<div class="h-8 w-8 rounded-lg bg-accents-2/50 group-hover:bg-accents-2 flex items-center justify-center text-xs font-bold text-accents-6 group-hover:text-foreground transition-colors flex-shrink-0">
|
||||||
@@ -185,7 +187,7 @@ $getInitials = function($name) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Dropdown -->
|
<!-- Dropdown -->
|
||||||
<div id="session-dropdown" class="absolute top-full left-3 w-[calc(100%-1.5rem)] z-50 mt-1 bg-background border border-accents-2 rounded-lg shadow-lg overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none">
|
<div id="session-dropdown" class="absolute top-full left-3 w-[calc(100%-1.5rem)] z-50 mt-1 bg-background border border-accents-2 rounded-lg shadow-lg overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none dropdown-bridge" onmouseenter="if(typeof menuTimeout !== 'undefined') clearTimeout(menuTimeout)">
|
||||||
<div class="py-1 max-h-60 overflow-y-auto">
|
<div class="py-1 max-h-60 overflow-y-auto">
|
||||||
<div class="px-3 py-2 text-xs font-semibold text-accents-5 uppercase tracking-wider bg-accents-1/50 border-b border-accents-2" data-i18n="sidebar.switch_session">
|
<div class="px-3 py-2 text-xs font-semibold text-accents-5 uppercase tracking-wider bg-accents-1/50 border-b border-accents-2" data-i18n="sidebar.switch_session">
|
||||||
Switch Session
|
Switch Session
|
||||||
@@ -377,11 +379,37 @@ $getInitials = function($name) {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Voucher Templates -->
|
<!-- Voucher Templates -->
|
||||||
<a href="/settings/templates" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= $isTemplates ? 'bg-white/40 dark:bg-white/5 shadow-sm text-foreground ring-1 ring-white/10' : 'text-accents-6 hover:text-foreground hover:bg-white/5' ?>">
|
<a href="/settings/voucher-templates" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= $isTemplates ? 'bg-white/40 dark:bg-white/5 shadow-sm text-foreground ring-1 ring-white/10' : 'text-accents-6 hover:text-foreground hover:bg-white/5' ?>">
|
||||||
<i data-lucide="file-code" class="w-4 h-4"></i>
|
<i data-lucide="file-code" class="w-4 h-4"></i>
|
||||||
<span data-i18n="sidebar.templates">Templates</span>
|
<span data-i18n="sidebar.templates">Templates</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Support Separator -->
|
||||||
|
<div class="pt-4 pb-1 px-3">
|
||||||
|
<div class="text-xs font-semibold text-accents-5 uppercase tracking-wider" data-i18n="sidebar.support">Support</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Docs -->
|
||||||
|
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||||
|
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||||
|
<span data-i18n="sidebar.docs">Documentation</span>
|
||||||
|
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Community -->
|
||||||
|
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||||
|
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||||
|
<span data-i18n="sidebar.community">Community</span>
|
||||||
|
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Repo -->
|
||||||
|
<a href="https://github.com/dyzulk/mivo" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||||
|
<i data-lucide="github" class="w-4 h-4"></i>
|
||||||
|
<span data-i18n="sidebar.repo">Repository</span>
|
||||||
|
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar Footer -->
|
<!-- Sidebar Footer -->
|
||||||
@@ -435,7 +463,7 @@ $getInitials = function($name) {
|
|||||||
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-mobile', this)" title="Change Language">
|
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-mobile', this)" title="Change Language">
|
||||||
<i data-lucide="languages" class="w-4 h-4"></i>
|
<i data-lucide="languages" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
<div id="lang-dropdown-mobile" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50">
|
<div id="lang-dropdown-mobile" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge" onmouseenter="if(typeof menuTimeout !== 'undefined') clearTimeout(menuTimeout)">
|
||||||
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
||||||
<?php
|
<?php
|
||||||
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function isActive($path, $current) {
|
|||||||
$menu = [
|
$menu = [
|
||||||
['label' => 'routers_title', 'url' => '/settings', 'namespace' => 'settings'],
|
['label' => 'routers_title', 'url' => '/settings', 'namespace' => 'settings'],
|
||||||
['label' => 'system', 'url' => '/settings/system', 'namespace' => 'settings'],
|
['label' => 'system', 'url' => '/settings/system', 'namespace' => 'settings'],
|
||||||
['label' => 'templates_title', 'url' => '/settings/templates', 'namespace' => 'settings'],
|
['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'],
|
||||||
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
|
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
|
||||||
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
|
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
|
||||||
<i data-lucide="ticket" class="h-4 w-4 text-accents-5"></i>
|
<i data-lucide="ticket" class="h-4 w-4 text-accents-5"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="voucher-code" class="form-input pl-10 h-11 text-lg font-mono tracking-wide" placeholder="Ex: QWASZX" required autofocus autocomplete="off">
|
<input type="text" id="voucher-code" class="form-input pl-10 h-11 text-lg font-mono tracking-wide" placeholder="Ex: QWASZX" data-i18n="status.code_placeholder" required autofocus autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,13 +55,6 @@
|
|||||||
|
|
||||||
<!-- Logic Script -->
|
<!-- Logic Script -->
|
||||||
<script>
|
<script>
|
||||||
// Initialize Input placeholder
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const inp = document.getElementById('voucher-code');
|
|
||||||
if(inp && window.i18n) {
|
|
||||||
inp.placeholder = window.i18n.t('status.code_placeholder');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkStatus(e) {
|
async function checkStatus(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -110,7 +103,7 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="relative p-5 md:p-6 border-b border-white/10 flex justify-between items-center bg-white/10 dark:bg-black/20">
|
<div class="relative p-5 md:p-6 border-b border-white/10 flex justify-between items-center bg-white/10 dark:bg-black/20">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-[10px] text-accents-5 font-bold uppercase tracking-widest block mb-0.5">Voucher Code</span>
|
<span class="text-[10px] text-accents-5 font-bold uppercase tracking-widest block mb-0.5">${window.i18n.t('status.code')}</span>
|
||||||
<span class="font-mono text-xl md:text-2xl font-black tracking-tighter text-foreground">${d.username}</span>
|
<span class="font-mono text-xl md:text-2xl font-black tracking-tighter text-foreground">${d.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 rounded-full text-[10px] font-black uppercase tracking-[0.15em] border ${statusColor} shadow-sm backdrop-blur-sm bg-opacity-80">
|
<div class="px-3 py-1.5 rounded-full text-[10px] font-black uppercase tracking-[0.15em] border ${statusColor} shadow-sm backdrop-blur-sm bg-opacity-80">
|
||||||
@@ -121,7 +114,7 @@
|
|||||||
<!-- Data Usage Bar -->
|
<!-- Data Usage Bar -->
|
||||||
<div class="relative p-5 md:p-6 pb-2">
|
<div class="relative p-5 md:p-6 pb-2">
|
||||||
<div class="flex justify-between items-end mb-2">
|
<div class="flex justify-between items-end mb-2">
|
||||||
<span class="text-xs font-bold text-accents-5 uppercase tracking-wide">Data Remaining</span>
|
<span class="text-xs font-bold text-accents-5 uppercase tracking-wide">${window.i18n.t('status.data_remaining')}</span>
|
||||||
<span class="text-lg font-black text-blue-600 dark:text-blue-400 font-mono tracking-tight">${d.data_left}</span>
|
<span class="text-lg font-black text-blue-600 dark:text-blue-400 font-mono tracking-tight">${d.data_left}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-2.5 bg-accents-2 rounded-full overflow-hidden shadow-inner ring-1 ring-black/5 dark:ring-white/5">
|
<div class="w-full h-2.5 bg-accents-2 rounded-full overflow-hidden shadow-inner ring-1 ring-black/5 dark:ring-white/5">
|
||||||
@@ -130,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right mt-1.5">
|
<div class="text-right mt-1.5">
|
||||||
<span class="text-[10px] font-semibold text-accents-4 uppercase tracking-wider">Used: <span class="text-foreground">${d.data_used}</span></span>
|
<span class="text-[10px] font-semibold text-accents-4 uppercase tracking-wider">${window.i18n.t('status.used')}: <span class="text-foreground">${d.data_used}</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,19 +132,19 @@
|
|||||||
<table class="w-full text-sm text-left">
|
<table class="w-full text-sm text-left">
|
||||||
<tbody class="divide-y divide-white/10">
|
<tbody class="divide-y divide-white/10">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Package</td>
|
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.package')}</td>
|
||||||
<td class="py-3 text-right font-bold text-foreground font-mono">${d.profile}</td>
|
<td class="py-3 text-right font-bold text-foreground font-mono">${d.profile}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Validity</td>
|
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.validity')}</td>
|
||||||
<td class="py-3 text-right font-bold text-foreground font-mono">${d.validity}</td>
|
<td class="py-3 text-right font-bold text-foreground font-mono">${d.validity}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Uptime</td>
|
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.uptime')}</td>
|
||||||
<td class="py-3 text-right font-medium text-foreground font-mono">${d.uptime_used}</td>
|
<td class="py-3 text-right font-medium text-foreground font-mono">${d.uptime_used}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Expires</td>
|
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.expires')}</td>
|
||||||
<td class="py-3 text-right font-medium text-foreground font-mono">${d.expiration}</td>
|
<td class="py-3 text-right font-medium text-foreground font-mono">${d.expiration}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -160,44 +153,32 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Swal.fire({
|
Mivo.alert('success', window.i18n.t('status.details_title'), htmlContent, {
|
||||||
title: 'Voucher Details',
|
customClass: { popup: 'w-full max-w-md' } // Override width only, others merged
|
||||||
html: htmlContent,
|
|
||||||
icon: 'success', // Using success icon for positive result
|
|
||||||
confirmButtonText: 'OK',
|
|
||||||
customClass: {
|
|
||||||
popup: 'swal2-premium-card w-full max-w-md', // Ensure good width
|
|
||||||
confirmButton: 'btn btn-primary w-full',
|
|
||||||
},
|
|
||||||
buttonsStyling: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
Swal.fire({
|
Mivo.alert('error',
|
||||||
icon: 'error',
|
window.i18n.t('status.not_found_title'),
|
||||||
title: 'Voucher Not Found',
|
json.message && json.message !== 'Voucher Not Found' ? json.message : window.i18n.t('status.not_found_desc'),
|
||||||
text: json.message || "The voucher code you entered does not exist.",
|
{
|
||||||
confirmButtonText: 'Try Again',
|
confirmButtonText: window.i18n.t('status.try_again'),
|
||||||
customClass: {
|
|
||||||
popup: 'swal2-premium-card',
|
|
||||||
confirmButton: 'btn btn-primary',
|
|
||||||
},
|
|
||||||
buttonsStyling: false,
|
|
||||||
didClose: () => {
|
didClose: () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.getElementById('voucher-code');
|
const el = document.getElementById('voucher-code');
|
||||||
if(el) { el.focus(); el.select(); }
|
if(el) { el.focus(); el.select(); }
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
title: 'System Error',
|
title: window.i18n.t('errors.500_title'),
|
||||||
text: 'Unable to connect to the server.',
|
text: window.i18n.t('errors.500_desc'),
|
||||||
confirmButtonText: 'Close',
|
confirmButtonText: 'Close',
|
||||||
customClass: {
|
customClass: {
|
||||||
popup: 'swal2-premium-card',
|
popup: 'swal2-premium-card',
|
||||||
|
|||||||
@@ -54,8 +54,18 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($packages as $pkg): ?>
|
<?php foreach ($packages as $pkg): ?>
|
||||||
<tr class="table-row-item group"
|
<tr class="table-row-item group"
|
||||||
data-name="<?= strtolower($pkg['name']) ?>"
|
data-id="<?= htmlspecialchars($pkg['id']) ?>"
|
||||||
data-price="<?= $pkg['price'] ?>">
|
data-name="<?= htmlspecialchars($pkg['name']) ?>"
|
||||||
|
data-profile="<?= htmlspecialchars($pkg['profile']) ?>"
|
||||||
|
data-prefix="<?= htmlspecialchars($pkg['prefix']) ?>"
|
||||||
|
data-price="<?= htmlspecialchars($pkg['price']) ?>"
|
||||||
|
data-selling-price="<?= htmlspecialchars($pkg['selling_price'] ?? $pkg['price']) ?>"
|
||||||
|
data-time-limit="<?= htmlspecialchars($pkg['time_limit']) ?>"
|
||||||
|
data-data-limit="<?= htmlspecialchars($pkg['data_limit']) ?>"
|
||||||
|
data-char-length="<?= htmlspecialchars($pkg['char_length']) ?>"
|
||||||
|
data-color="<?= htmlspecialchars($pkg['color']) ?>"
|
||||||
|
data-comment="<?= htmlspecialchars($pkg['comment']) ?>">
|
||||||
|
|
||||||
<td class="font-medium text-foreground">
|
<td class="font-medium text-foreground">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-3 h-3 rounded-full <?= htmlspecialchars($pkg['color']) ?>"></div>
|
<div class="w-3 h-3 rounded-full <?= htmlspecialchars($pkg['color']) ?>"></div>
|
||||||
@@ -76,7 +86,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" class="btn-icon" title="Edit">
|
<button type="button" onclick="openModal('edit', this)" class="btn-icon" title="Edit">
|
||||||
<i data-lucide="edit-3" class="w-4 h-4"></i>
|
<i data-lucide="edit-3" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,29 +111,23 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Modal -->
|
<!-- Template for Add/Edit Package Form -->
|
||||||
<div id="modal-overlay" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center opacity-0 transition-opacity duration-200">
|
<template id="package-form-template">
|
||||||
<div id="modal-content" class="card w-full max-w-lg mx-4 transform scale-95 transition-transform duration-200 overflow-hidden p-0">
|
<form id="qp-form" action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="space-y-4 text-left">
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-accents-2 bg-accents-1/30">
|
|
||||||
<h3 class="text-lg font-bold text-foreground" id="modal-title" data-i18n="quick_print.add_package">Add Package</h3>
|
|
||||||
<button onclick="closeModal()" class="text-accents-5 hover:text-foreground">
|
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
|
<!-- Hidden ID for Edit Mode (will be disabled/removed for Add) -->
|
||||||
|
<input type="hidden" name="id" id="form-id" disabled>
|
||||||
|
|
||||||
<!-- Quick Inputs Grid -->
|
<!-- Quick Inputs Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="col-span-1 md:col-span-2">
|
<div class="col-span-1 md:col-span-2">
|
||||||
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label>
|
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label>
|
||||||
<input type="text" name="name" required class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary placeholder:text-accents-3" placeholder="e.g. 3 Hours Voucher">
|
<input type="text" name="name" required class="w-full" placeholder="e.g. 3 Hours Voucher">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
|
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
|
||||||
<select name="profile" class="custom-select w-full" data-search="true">
|
<select name="profile" class="w-full" data-search="true">
|
||||||
<?php foreach($profiles as $p): ?>
|
<?php foreach($profiles as $p): ?>
|
||||||
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -132,7 +136,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
|
<label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
|
||||||
<select name="color" class="custom-select w-full">
|
<select name="color" class="w-full">
|
||||||
<option value="bg-blue-500" data-i18n="colors.blue">Blue</option>
|
<option value="bg-blue-500" data-i18n="colors.blue">Blue</option>
|
||||||
<option value="bg-red-500" data-i18n="colors.red">Red</option>
|
<option value="bg-red-500" data-i18n="colors.red">Red</option>
|
||||||
<option value="bg-green-500" data-i18n="colors.green">Green</option>
|
<option value="bg-green-500" data-i18n="colors.green">Green</option>
|
||||||
@@ -146,22 +150,22 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.price">Price (Rp)</label>
|
<label class="form-label" data-i18n="quick_print.price">Price (Rp)</label>
|
||||||
<input type="number" name="price" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="5000">
|
<input type="number" name="price" class="w-full" placeholder="5000">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.selling_price">Selling Price</label>
|
<label class="form-label" data-i18n="quick_print.selling_price">Selling Price</label>
|
||||||
<input type="number" name="selling_price" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="Default same">
|
<input type="number" name="selling_price" class="w-full" placeholder="Default same">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.prefix">Prefix</label>
|
<label class="form-label" data-i18n="quick_print.prefix">Prefix</label>
|
||||||
<input type="text" name="prefix" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="Example: VIP-">
|
<input type="text" name="prefix" class="w-full" placeholder="Example: VIP-">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
|
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
|
||||||
<select name="char_length" class="custom-select w-full">
|
<select name="char_length" class="w-full">
|
||||||
<option value="4" selected data-i18n="common.char_length" data-i18n-params='{"n": 4}'>4 Characters</option>
|
<option value="4" selected data-i18n="common.char_length" data-i18n-params='{"n": 4}'>4 Characters</option>
|
||||||
<option value="6" data-i18n="common.char_length" data-i18n-params='{"n": 6}'>6 Characters</option>
|
<option value="6" data-i18n="common.char_length" data-i18n-params='{"n": 6}'>6 Characters</option>
|
||||||
<option value="8" data-i18n="common.char_length" data-i18n-params='{"n": 8}'>8 Characters</option>
|
<option value="8" data-i18n="common.char_length" data-i18n-params='{"n": 8}'>8 Characters</option>
|
||||||
@@ -170,27 +174,21 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.time_limit">Time Limit</label>
|
<label class="form-label" data-i18n="quick_print.time_limit">Time Limit</label>
|
||||||
<input type="text" name="time_limit" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="3h">
|
<input type="text" name="time_limit" class="w-full" placeholder="3h">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.data_limit">Data Limit</label>
|
<label class="form-label" data-i18n="quick_print.data_limit">Data Limit</label>
|
||||||
<input type="text" name="data_limit" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="500M (Optional)">
|
<input type="text" name="data_limit" class="w-full" placeholder="500M (Optional)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-1 md:col-span-2">
|
<div class="col-span-1 md:col-span-2">
|
||||||
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
|
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
|
||||||
<input type="text" name="comment" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="Description or Note">
|
<input type="text" name="comment" class="w-full" placeholder="Description or Note">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-accents-2 mt-4">
|
|
||||||
<button type="button" onclick="closeModal()" class="btn btn-secondary" data-i18n="common.cancel">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary" data-i18n="quick_print.save_package">Save Package</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
class TableManager {
|
class TableManager {
|
||||||
@@ -298,27 +296,63 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlay = document.getElementById('modal-overlay');
|
function openModal(mode, btn = null) {
|
||||||
const content = document.getElementById('modal-content');
|
const template = document.getElementById('package-form-template').innerHTML;
|
||||||
|
|
||||||
function openModal(mode) {
|
let title = window.i18n ? window.i18n.t('quick_print.add_package') : 'Add Package';
|
||||||
overlay.classList.remove('hidden');
|
let saveBtn = window.i18n ? window.i18n.t('quick_print.save_package') : 'Save Package';
|
||||||
// Trigger reflow
|
|
||||||
void overlay.offsetWidth;
|
|
||||||
|
|
||||||
overlay.classList.remove('opacity-0');
|
// Validation Callback
|
||||||
content.classList.add('open');
|
const preConfirmFn = () => {
|
||||||
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
// Population Callback (Runs BEFORE CustomSelect init)
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
const form = popup.querySelector('form');
|
||||||
|
|
||||||
|
// Update Route Logic Here if needed, or rely on Hidden ID
|
||||||
|
// For now backend handles update if ID is present
|
||||||
|
form.action = "/<?= htmlspecialchars($session) ?>/quick-print/update";
|
||||||
|
|
||||||
|
// Populate inputs
|
||||||
|
form.querySelector('[name="id"]').value = row.dataset.id;
|
||||||
|
form.querySelector('[name="id"]').disabled = false;
|
||||||
|
|
||||||
|
form.querySelector('[name="name"]').value = row.dataset.name;
|
||||||
|
form.querySelector('[name="price"]').value = row.dataset.price;
|
||||||
|
form.querySelector('[name="selling_price"]').value = row.dataset.sellingPrice;
|
||||||
|
form.querySelector('[name="prefix"]').value = row.dataset.prefix;
|
||||||
|
form.querySelector('[name="time_limit"]').value = row.dataset.timeLimit;
|
||||||
|
form.querySelector('[name="data_limit"]').value = row.dataset.dataLimit;
|
||||||
|
form.querySelector('[name="comment"]').value = row.dataset.comment;
|
||||||
|
|
||||||
|
// Selects (Just setting value works because CustomSelect hasn't init yet!)
|
||||||
|
const profileSel = form.querySelector('[name="profile"]');
|
||||||
|
if(profileSel) profileSel.value = row.dataset.profile;
|
||||||
|
|
||||||
|
const colorSel = form.querySelector('[name="color"]');
|
||||||
|
if(colorSel) colorSel.value = row.dataset.color;
|
||||||
|
|
||||||
|
const charSel = form.querySelector('[name="char_length"]');
|
||||||
|
if(charSel) charSel.value = row.dataset.charLength;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
title = window.i18n ? 'Edit Package' : 'Edit Package';
|
||||||
|
saveBtn = window.i18n ? 'Update Package' : 'Update Package';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
// Pass callbacks to helper
|
||||||
overlay.classList.add('opacity-0');
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
|
||||||
content.classList.remove('open');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<!-- Spacer -->
|
<!-- Spacer -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 w-full md:w-auto">
|
<div class="flex gap-2 w-full md:w-auto">
|
||||||
<button onclick="openModal('addModal')" class="btn btn-primary w-full md:w-auto">
|
<button onclick="openCorsModal()" 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>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.add_rule">Add CORS Rule</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,12 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<tbody id="table-body">
|
<tbody id="table-body">
|
||||||
<?php if (!empty($rules)): ?>
|
<?php if (!empty($rules)): ?>
|
||||||
<?php foreach ($rules as $rule): ?>
|
<?php foreach ($rules as $rule): ?>
|
||||||
<tr class="table-row-item">
|
<tr class="table-row-item"
|
||||||
|
data-rule-id="<?= $rule['id'] ?>"
|
||||||
|
data-origin="<?= htmlspecialchars($rule['origin']) ?>"
|
||||||
|
data-headers="<?= htmlspecialchars(implode(', ', $rule['headers_arr'])) ?>"
|
||||||
|
data-max-age="<?= $rule['max_age'] ?>"
|
||||||
|
data-methods='<?= json_encode($rule['methods_arr']) ?>'>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($rule['origin']) ?></div>
|
<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>
|
<div class="text-xs text-accents-4">Max Age: <?= $rule['max_age'] ?>s</div>
|
||||||
@@ -57,7 +62,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right text-sm font-medium">
|
<td class="text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||||
<button onclick="editRule(<?= htmlspecialchars(json_encode($rule)) ?>)" class="btn-icon" title="Edit">
|
<button onclick="openCorsModal(this.closest('tr'))" class="btn-icon" title="Edit">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</button>
|
</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">
|
<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">
|
||||||
@@ -85,136 +90,71 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
function openModal(id) {
|
async function openCorsModal(row = null) {
|
||||||
const modal = document.getElementById(id);
|
const isEdit = !!row;
|
||||||
const content = modal.querySelector('.modal-content');
|
const title = isEdit ? (window.i18n ? window.i18n.t('settings.edit_rule') : 'Edit CORS Rule') : (window.i18n ? window.i18n.t('settings.add_rule') : 'Add CORS Rule');
|
||||||
modal.classList.remove('hidden');
|
const template = document.getElementById('cors-form-template').innerHTML;
|
||||||
|
const saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
|
|
||||||
// Use double requestAnimationFrame to ensure the browser has painted the hidden->block change
|
const preConfirmFn = () => {
|
||||||
// before we trigger the opacity/transform transitions.
|
const form = document.getElementById('cors-form');
|
||||||
requestAnimationFrame(() => {
|
if (!form.checkValidity()) {
|
||||||
requestAnimationFrame(() => {
|
form.reportValidity();
|
||||||
modal.classList.remove('opacity-0');
|
return false;
|
||||||
content.classList.remove('scale-95', 'opacity-0');
|
}
|
||||||
content.classList.add('scale-100', 'opacity-100');
|
form.submit();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
const form = popup.querySelector('#cors-form');
|
||||||
|
if (isEdit) {
|
||||||
|
form.action = '/settings/api-cors/update';
|
||||||
|
form.querySelector('[name="id"]').value = row.dataset.ruleId;
|
||||||
|
form.querySelector('[name="origin"]').value = row.dataset.origin;
|
||||||
|
form.querySelector('[name="headers"]').value = row.dataset.headers;
|
||||||
|
form.querySelector('[name="max_age"]').value = row.dataset.maxAge;
|
||||||
|
|
||||||
|
const methods = JSON.parse(row.dataset.methods || '[]');
|
||||||
|
form.querySelectorAll('[name="methods[]"]').forEach(cb => {
|
||||||
|
cb.checked = methods.includes(cb.value);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function closeModal(id) {
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
|
<template id="cors-form-template">
|
||||||
|
<form action="/settings/api-cors/store" method="POST" id="cors-form" class="space-y-4 text-left">
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" data-i18n="settings.origin">Origin</label>
|
||||||
|
<input type="text" name="origin" class="w-full" placeholder="https://example.com or *" required>
|
||||||
|
<p class="text-[10px] text-orange-500 dark:text-orange-400 mt-1 font-medium">Use * for all origins (not recommended for production).</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" 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 gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="checkbox" <?= in_array($m, ['GET', 'POST']) ? 'checked' : '' ?>>
|
||||||
|
<span class="text-sm font-medium"><?= $m ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" data-i18n="settings.headers">Allowed Headers</label>
|
||||||
|
<input type="text" name="headers" class="w-full" value="*" placeholder="Content-Type, Authorization, *">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" data-i18n="settings.max_age">Max Age (seconds)</label>
|
||||||
|
<input type="number" name="max_age" class="w-full" value="3600">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
<?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'; ?>
|
|
||||||
@@ -22,9 +22,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
<!-- Spacer or Breadcrumbs if needed -->
|
<!-- Spacer or Breadcrumbs if needed -->
|
||||||
</div>
|
</div>
|
||||||
<a href="/settings/add" class="btn btn-primary w-full md:w-auto">
|
<button onclick="openRouterModal('add')" class="btn btn-primary w-full md:w-auto">
|
||||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add Router
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="routers.add_router_title">Add Router</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($routers)): ?>
|
<?php if (empty($routers)): ?>
|
||||||
@@ -34,9 +34,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium mb-2">No routers configured</h3>
|
<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>
|
<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">
|
<button onclick="openRouterModal('add')" class="btn btn-primary">
|
||||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Connect Router
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="routers.add_router_title">Connect Router</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -53,7 +53,17 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($routers as $router): ?>
|
<?php foreach ($routers as $router): ?>
|
||||||
<tr>
|
<tr class="router-row"
|
||||||
|
data-id="<?= $router['id'] ?>"
|
||||||
|
data-sessname="<?= htmlspecialchars($router['session_name']) ?>"
|
||||||
|
data-ipmik="<?= htmlspecialchars($router['ip_address']) ?>"
|
||||||
|
data-usermik="<?= htmlspecialchars($router['username']) ?>"
|
||||||
|
data-hotspotname="<?= htmlspecialchars($router['hotspot_name']) ?>"
|
||||||
|
data-dnsname="<?= htmlspecialchars($router['dns_name']) ?>"
|
||||||
|
data-iface="<?= htmlspecialchars($router['interface'] ?? 'ether1') ?>"
|
||||||
|
data-currency="<?= htmlspecialchars($router['currency'] ?? 'Rp') ?>"
|
||||||
|
data-areload="<?= htmlspecialchars($router['reload_interval'] ?? '10') ?>"
|
||||||
|
data-quick-access="<?= $router['quick_access'] ?? 0 ?>">
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center">
|
<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">
|
<div class="h-8 w-8 rounded bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
|
||||||
@@ -80,9 +90,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<a href="/<?= htmlspecialchars($router['session_name']) ?>/dashboard" class="btn btn-secondary btn-sm h-8 px-3">
|
<a href="/<?= htmlspecialchars($router['session_name']) ?>/dashboard" class="btn btn-secondary btn-sm h-8 px-3">
|
||||||
Open
|
Open
|
||||||
</a>
|
</a>
|
||||||
<a href="/settings/edit/<?= $router['id'] ?>" class="btn btn-secondary btn-sm h-8 px-3" title="Edit">
|
<button onclick="openRouterModal('edit', this)" class="btn btn-secondary btn-sm h-8 px-3" title="Edit">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</a>
|
</button>
|
||||||
<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">
|
<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'] ?>">
|
<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">
|
<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">
|
||||||
@@ -98,13 +108,239 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<div class="text-sm text-accents-5">
|
<div class="text-sm text-accents-5">
|
||||||
Showing all <?= count($routers) ?> stored sessions
|
Showing all <?= count($routers) ?> stored sessions
|
||||||
</div>
|
</div>
|
||||||
<a href="/settings/add" class="btn btn-primary btn-sm w-full sm:w-auto justify-center">
|
<button onclick="openRouterModal('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
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="routers.add_router_title">Add New</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template id="router-form-template">
|
||||||
|
<div class="text-left">
|
||||||
|
<form id="router-form" action="/settings/store" method="POST" class="space-y-6">
|
||||||
|
<input type="hidden" name="id" id="form-id">
|
||||||
|
|
||||||
|
<!-- Session Settings -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold mb-3 flex items-center gap-2" data-i18n="routers.session_settings">
|
||||||
|
<i data-lucide="settings" class="w-4 h-4"></i> Session Settings
|
||||||
|
</h2>
|
||||||
|
<div class="max-w-md space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="home.session_name">Session Name</label>
|
||||||
|
<input class="w-full" type="text" name="sessname" id="sessname" placeholder="e.g. router-jakarta-1" required/>
|
||||||
|
<p class="text-[10px] text-accents-4 uppercase tracking-tighter mt-1">
|
||||||
|
<span data-i18n="routers.unique_id">Unique ID:</span> <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 flex-shrink-0" value="1">
|
||||||
|
<label for="quick_access" class="text-xs font-bold cursor-pointer select-none whitespace-nowrap uppercase tracking-wider" data-i18n="routers.show_quick_access">Show in Quick Access</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Details -->
|
||||||
|
<div class="border-t border-white/5 pt-6">
|
||||||
|
<h2 class="text-base font-semibold mb-3 flex items-center gap-2" data-i18n="routers.connection_details">
|
||||||
|
<i data-lucide="zap" class="w-4 h-4"></i> Connection Details
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="space-y-1 md:col-span-1">
|
||||||
|
<label class="form-label" data-i18n="home.ip_address">IP Address</label>
|
||||||
|
<input class="w-full" type="text" name="ipmik" placeholder="192.168.88.1" required/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="login.username">Username</label>
|
||||||
|
<input class="w-full" type="text" name="usermik" placeholder="admin" required/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="login.password">Password</label>
|
||||||
|
<input class="w-full" type="password" name="passmik" id="passmik" placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hotspot Information -->
|
||||||
|
<div class="border-t border-white/5 pt-6">
|
||||||
|
<h2 class="text-base font-semibold mb-3 flex items-center gap-2" data-i18n="routers.hotspot_info">
|
||||||
|
<i data-lucide="globe" class="w-4 h-4"></i> Hotspot Information
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="home.hotspot_name">Hotspot Name</label>
|
||||||
|
<input class="w-full" type="text" name="hotspotname" placeholder="My Hotspot ID" required/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="routers.dns_name">DNS Name</label>
|
||||||
|
<input class="w-full" type="text" name="dnsname" placeholder="hotspot.net" required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="routers.traffic_interface">Traffic Interface</label>
|
||||||
|
<div class="flex w-full gap-2">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<select class="w-full" name="iface" id="iface" data-search="true" required>
|
||||||
|
<option value="ether1">ether1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="check-interface-btn" class="btn btn-secondary whitespace-nowrap px-3" title="Check connection">
|
||||||
|
<i data-lucide="refresh-cw" class="w-4 h-4 mr-1"></i>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-tight" data-i18n="routers.check_connection">Check Connection</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="routers.currency">Currency</label>
|
||||||
|
<input class="w-full" type="text" name="currency" value="Rp" required/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="routers.auto_reload">Reload (s)</label>
|
||||||
|
<input class="w-full" type="number" min="2" name="areload" value="10" required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openRouterModal(mode, btn = null) {
|
||||||
|
const template = document.getElementById('router-form-template').innerHTML;
|
||||||
|
|
||||||
|
let title = window.i18n ? window.i18n.t('routers.add_router_title') : 'Add Router';
|
||||||
|
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
title = window.i18n ? window.i18n.t('routers.edit_router_title') : 'Edit Router';
|
||||||
|
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
|
||||||
|
}
|
||||||
|
|
||||||
|
const preConfirmFn = () => {
|
||||||
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
const form = popup.querySelector('form');
|
||||||
|
|
||||||
|
// --- Interface Check Logic ---
|
||||||
|
const checkBtn = form.querySelector('#check-interface-btn');
|
||||||
|
const ifaceSelect = form.querySelector('#iface');
|
||||||
|
|
||||||
|
if (checkBtn && ifaceSelect) {
|
||||||
|
checkBtn.addEventListener('click', async () => {
|
||||||
|
const originalHTML = checkBtn.innerHTML;
|
||||||
|
checkBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 mr-1 animate-spin"></i><span class="text-xs font-bold uppercase tracking-tight">Checking...</span>';
|
||||||
|
checkBtn.disabled = true;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
|
||||||
|
const ip = form.querySelector('[name="ipmik"]').value;
|
||||||
|
const user = form.querySelector('[name="usermik"]').value;
|
||||||
|
const pass = form.querySelector('[name="passmik"]').value;
|
||||||
|
const id = form.querySelector('[name="id"]').value || null;
|
||||||
|
|
||||||
|
if (!ip || !user) {
|
||||||
|
Mivo.toast('warning', 'Missing Details', 'IP Address and Username are required');
|
||||||
|
checkBtn.innerHTML = originalHTML;
|
||||||
|
checkBtn.disabled = false;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/router/interfaces', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ip, user, password: pass, id })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success || !data.interfaces) {
|
||||||
|
Mivo.toast('error', 'Fetch Failed', data.error || 'Check credentials');
|
||||||
|
} else {
|
||||||
|
ifaceSelect.innerHTML = '';
|
||||||
|
data.interfaces.forEach(iface => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = iface;
|
||||||
|
opt.textContent = iface;
|
||||||
|
ifaceSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.Mivo && window.Mivo.components.Select) {
|
||||||
|
const instance = window.Mivo.components.Select.get(ifaceSelect);
|
||||||
|
if (instance) instance.refresh();
|
||||||
|
}
|
||||||
|
Mivo.toast('success', 'Success', 'Interfaces loaded');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Mivo.toast('error', 'Error', 'Connection failed');
|
||||||
|
} finally {
|
||||||
|
checkBtn.innerHTML = originalHTML;
|
||||||
|
checkBtn.disabled = false;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session Name Formatting ---
|
||||||
|
const sessInput = form.querySelector('[name="sessname"]');
|
||||||
|
const sessPreview = form.querySelector('#sessname-preview');
|
||||||
|
if (sessInput && sessPreview) {
|
||||||
|
sessInput.addEventListener('input', (e) => {
|
||||||
|
let val = e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-');
|
||||||
|
e.target.value = val;
|
||||||
|
sessPreview.textContent = val || '...';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
form.action = "/settings/update";
|
||||||
|
|
||||||
|
const idInput = form.querySelector('#form-id');
|
||||||
|
idInput.disabled = false;
|
||||||
|
idInput.value = row.dataset.id;
|
||||||
|
|
||||||
|
form.querySelector('[name="sessname"]').value = row.dataset.sessname || '';
|
||||||
|
if(sessPreview) sessPreview.textContent = row.dataset.sessname || '';
|
||||||
|
|
||||||
|
form.querySelector('[name="ipmik"]').value = row.dataset.ipmik || '';
|
||||||
|
form.querySelector('[name="usermik"]').value = row.dataset.usermik || '';
|
||||||
|
form.querySelector('[name="hotspotname"]').value = row.dataset.hotspotname || '';
|
||||||
|
form.querySelector('[name="dnsname"]').value = row.dataset.dnsname || '';
|
||||||
|
form.querySelector('[name="currency"]').value = row.dataset.currency || 'Rp';
|
||||||
|
form.querySelector('[name="areload"]').value = row.dataset.areload || '10';
|
||||||
|
|
||||||
|
const quickCheck = form.querySelector('#quick_access');
|
||||||
|
if(quickCheck) quickCheck.checked = row.dataset.quickAccess == '1';
|
||||||
|
|
||||||
|
// Handle Interface Select
|
||||||
|
const currentIface = row.dataset.iface || 'ether1';
|
||||||
|
ifaceSelect.innerHTML = `<option value="${currentIface}" selected>${currentIface}</option>`;
|
||||||
|
if (window.Mivo && window.Mivo.components.Select) {
|
||||||
|
const instance = window.Mivo.components.Select.get(ifaceSelect);
|
||||||
|
if (instance) instance.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password is not populated for security, hint is in placeholder
|
||||||
|
form.querySelector('[name="passmik"]').placeholder = '•••••••• (unchanged)';
|
||||||
|
form.querySelector('[name="passmik"]').required = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<!-- Upload Section -->
|
<!-- Upload Section -->
|
||||||
<section>
|
<section>
|
||||||
<div class="card p-8 border-dashed border-2 bg-accents-1 hover:bg-background transition-colors text-center relative group">
|
<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">
|
<form action="/settings/logos/upload" method="POST" enctype="multipart/form-data" class="absolute inset-0 w-full h-full cursor-pointer z-50">
|
||||||
<input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.svg,.gif" onchange="this.form.submit()" class="form-control-file">
|
<input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.svg,.gif" onchange="this.form.submit()" class="block w-full h-full opacity-0 cursor-pointer">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-center pointer-events-none">
|
<div class="flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 flex-shrink-0">
|
<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">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/settings/templates" class="text-accents-5 hover:text-foreground transition-colors">
|
<a href="/settings/voucher-templates" class="text-accents-5 hover:text-foreground transition-colors">
|
||||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-xl font-bold tracking-tight text-foreground"><?= $title ?></h1>
|
<h1 class="text-xl font-bold tracking-tight text-foreground"><?= $title ?></h1>
|
||||||
</div>
|
</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">
|
<form id="templateForm" action="<?= $isEdit ? '/settings/voucher-templates/update' : '/settings/voucher-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): ?>
|
<?php if ($isEdit): ?>
|
||||||
<input type="hidden" name="id" value="<?= $template['id'] ?>">
|
<input type="hidden" name="id" value="<?= $template['id'] ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -21,7 +21,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
<!-- Spacer -->
|
<!-- Spacer -->
|
||||||
</div>
|
</div>
|
||||||
<a href="/settings/templates/add" class="btn btn-primary w-full sm:w-auto justify-center">
|
<a href="/settings/voucher-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>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||||
<span data-i18n="settings.new_template">New Template</span>
|
<span data-i18n="settings.new_template">New Template</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -37,7 +37,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
|
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<iframe
|
<iframe
|
||||||
data-src="/settings/templates/preview/default"
|
data-src="/settings/voucher-templates/preview/default"
|
||||||
src="about:blank"
|
src="about:blank"
|
||||||
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
@@ -66,7 +66,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
|
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<iframe
|
<iframe
|
||||||
data-src="/settings/templates/preview/<?= $tpl['id'] ?>"
|
data-src="/settings/voucher-templates/preview/<?= $tpl['id'] ?>"
|
||||||
src="about:blank"
|
src="about:blank"
|
||||||
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
@@ -87,10 +87,10 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<p class="text-sm text-accents-5 mb-4 line-clamp-1">Created: <?= htmlspecialchars($tpl['created_at']) ?></p>
|
<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">
|
<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">
|
<a href="/settings/voucher-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>
|
<i data-lucide="edit-3" class="w-4 h-4 mr-2"></i> <span data-i18n="common.edit">Edit</span>
|
||||||
</a>
|
</a>
|
||||||
<form action="/settings/templates/delete" method="POST" class="delete-template-form">
|
<form action="/settings/voucher-templates/delete" method="POST" class="delete-template-form">
|
||||||
<input type="hidden" name="id" value="<?= $tpl['id'] ?>">
|
<input type="hidden" name="id" value="<?= $tpl['id'] ?>">
|
||||||
<input type="hidden" name="template_name" value="<?= htmlspecialchars($tpl['name']) ?>">
|
<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">
|
<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">
|
||||||
@@ -12,7 +12,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<button onclick="location.reload()" class="btn btn-secondary">
|
<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>
|
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openModal('addModal')" class="btn btn-primary">
|
<button onclick="openSchedulerModal('add')" class="btn btn-primary">
|
||||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="system_tools.add_task">Add Task</span>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="system_tools.add_task">Add Task</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +54,14 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
$status = ($task['disabled'] === 'true') ? 'disabled' : 'enabled';
|
$status = ($task['disabled'] === 'true') ? 'disabled' : 'enabled';
|
||||||
?>
|
?>
|
||||||
<tr class="table-row-item"
|
<tr class="table-row-item"
|
||||||
data-name="<?= strtolower($task['name']) ?>"
|
data-id="<?= $task['.id'] ?>"
|
||||||
|
data-name="<?= htmlspecialchars($task['name']) ?>"
|
||||||
|
data-interval="<?= htmlspecialchars($task['interval']) ?>"
|
||||||
|
data-start-date="<?= htmlspecialchars($task['start-date'] ?? '') ?>"
|
||||||
|
data-start-time="<?= htmlspecialchars($task['start-time'] ?? '') ?>"
|
||||||
|
data-on-event="<?= htmlspecialchars($task['on-event']) ?>"
|
||||||
|
data-comment="<?= htmlspecialchars($task['comment'] ?? '') ?>"
|
||||||
|
data-search-name="<?= strtolower($task['name']) ?>"
|
||||||
data-status="<?= $status ?>">
|
data-status="<?= $status ?>">
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
@@ -72,7 +79,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right text-sm font-medium">
|
<td class="text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||||
<button onclick="editTask(<?= htmlspecialchars(json_encode($task)) ?>)" class="btn-icon" title="Edit">
|
<button onclick="openSchedulerModal('edit', this)" class="btn-icon" title="Edit">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
<form action="/<?= $session ?>/system/scheduler/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('system_tools.delete_task') : 'Delete Task?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete task <?= htmlspecialchars($task['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">
|
<form action="/<?= $session ?>/system/scheduler/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('system_tools.delete_task') : 'Delete Task?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete task <?= htmlspecialchars($task['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">
|
||||||
@@ -103,104 +110,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</div>
|
</div>
|
||||||
</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="system_tools.add_title">Add Scheduler Task</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="/<?= $session ?>/system/scheduler/store" method="POST" class="space-y-4">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.name">Name</label>
|
|
||||||
<input type="text" name="name" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.interval">Interval</label>
|
|
||||||
<input type="text" name="interval" class="form-control" value="1d 00:00:00" placeholder="1d 00:00:00">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_date">Start Date</label>
|
|
||||||
<input type="text" name="start_date" class="form-control" value="Jan/01/1970">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_time">Start Time</label>
|
|
||||||
<input type="text" name="start_time" class="form-control" value="00:00:00">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.on_event">On Event (Script)</label>
|
|
||||||
<textarea name="on_event" class="form-control font-mono text-xs h-24" placeholder="/system reboot"></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.comment">Comment</label>
|
|
||||||
<input type="text" name="comment" class="form-control">
|
|
||||||
</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="system_tools.save_task">Save Task</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="system_tools.edit_title">Edit Scheduler Task</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="/<?= $session ?>/system/scheduler/update" method="POST" class="space-y-4">
|
|
||||||
<input type="hidden" name="id" id="edit_id">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.name">Name</label>
|
|
||||||
<input type="text" name="name" id="edit_name" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.interval">Interval</label>
|
|
||||||
<input type="text" name="interval" id="edit_interval" class="form-control">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_date">Start Date</label>
|
|
||||||
<input type="text" name="start_date" id="edit_start_date" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_time">Start Time</label>
|
|
||||||
<input type="text" name="start_time" id="edit_start_time" class="form-control">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.on_event">On Event (Script)</label>
|
|
||||||
<textarea name="on_event" id="edit_on_event" class="form-control font-mono text-xs h-24"></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.comment">Comment</label>
|
|
||||||
<input type="text" name="comment" id="edit_comment" 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="system_tools.update_task">Update Task</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
class TableManager {
|
class TableManager {
|
||||||
@@ -256,7 +166,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
update() {
|
update() {
|
||||||
this.filteredRows = this.allRows.filter(row => {
|
this.filteredRows = this.allRows.filter(row => {
|
||||||
const name = row.dataset.name || '';
|
const name = row.dataset.searchName || '';
|
||||||
|
|
||||||
if (this.filters.search && !name.includes(this.filters.search)) return false;
|
if (this.filters.search && !name.includes(this.filters.search)) return false;
|
||||||
|
|
||||||
@@ -308,42 +218,49 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(id) {
|
function openSchedulerModal(mode, btn = null) {
|
||||||
const modal = document.getElementById(id);
|
const template = document.getElementById('scheduler-form-template').innerHTML;
|
||||||
const content = modal.querySelector('.modal-content');
|
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
let title = window.i18n ? window.i18n.t('system_tools.add_title') : 'Add Scheduler Task';
|
||||||
// Force reflow
|
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
void modal.offsetWidth;
|
|
||||||
|
|
||||||
modal.classList.remove('opacity-0');
|
if (mode === 'edit') {
|
||||||
content.classList.remove('scale-95', 'opacity-0');
|
title = window.i18n ? window.i18n.t('system_tools.edit_title') : 'Edit Scheduler Task';
|
||||||
content.classList.add('scale-100', 'opacity-100');
|
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal(id) {
|
const preConfirmFn = () => {
|
||||||
const modal = document.getElementById(id);
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
const content = modal.querySelector('.modal-content');
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
modal.classList.add('opacity-0');
|
const onOpenedFn = (popup) => {
|
||||||
content.classList.remove('scale-100', 'opacity-100');
|
const form = popup.querySelector('form');
|
||||||
content.classList.add('scale-95', 'opacity-0');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
if (mode === 'edit' && btn) {
|
||||||
modal.classList.add('hidden');
|
const row = btn.closest('tr');
|
||||||
}, 300); // Match duration-300
|
form.action = "/<?= htmlspecialchars($session) ?>/system/scheduler/update";
|
||||||
}
|
|
||||||
|
|
||||||
function editTask(task) {
|
// Populate Hidden ID
|
||||||
document.getElementById('edit_id').value = task['.id'];
|
const idInput = form.querySelector('#form-id');
|
||||||
document.getElementById('edit_name').value = task['name'];
|
idInput.disabled = false;
|
||||||
document.getElementById('edit_interval').value = task['interval'];
|
idInput.value = row.dataset.id;
|
||||||
document.getElementById('edit_start_date').value = task['start-date'];
|
|
||||||
document.getElementById('edit_start_time').value = task['start-time'];
|
|
||||||
document.getElementById('edit_on_event').value = task['on-event'];
|
|
||||||
document.getElementById('edit_comment').value = task['comment'] ?? '';
|
|
||||||
|
|
||||||
openModal('editModal');
|
// Populate Fields
|
||||||
|
form.querySelector('[name="name"]').value = row.dataset.name || '';
|
||||||
|
form.querySelector('[name="interval"]').value = row.dataset.interval || '';
|
||||||
|
form.querySelector('[name="start_date"]').value = row.dataset.startDate || '';
|
||||||
|
form.querySelector('[name="start_time"]').value = row.dataset.startTime || '';
|
||||||
|
form.querySelector('[name="on_event"]').value = row.dataset.onEvent || '';
|
||||||
|
form.querySelector('[name="comment"]').value = row.dataset.comment || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -351,4 +268,40 @@ function editTask(task) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template id="scheduler-form-template">
|
||||||
|
<div class="text-left">
|
||||||
|
<form action="/<?= $session ?>/system/scheduler/store" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="id" id="form-id" disabled>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="system_tools.name">Name</label>
|
||||||
|
<input type="text" name="name" class="w-full" required>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="system_tools.interval">Interval</label>
|
||||||
|
<input type="text" name="interval" class="w-full" value="1d 00:00:00" placeholder="1d 00:00:00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="system_tools.start_date">Start Date</label>
|
||||||
|
<input type="text" name="start_date" class="w-full" value="Jan/01/1970">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="system_tools.start_time">Start Time</label>
|
||||||
|
<input type="text" name="start_time" class="w-full" value="00:00:00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="system_tools.on_event">On Event (Script)</label>
|
||||||
|
<textarea name="on_event" class="w-full font-mono text-xs h-32" placeholder="/system reboot"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
|
||||||
|
<input type="text" name="comment" class="w-full">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mivo",
|
"name": "mivo",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "This is a complete rewrite of Mikhmon v3 using a modern MVC architecture.\r It runs on a lightweight custom core designed for performance on low-end devices (STB/Android).",
|
"description": "This is a complete rewrite of Mikhmon v3 using a modern MVC architecture.\r It runs on a lightweight custom core designed for performance on low-end devices (STB/Android).",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -791,15 +791,21 @@ body {
|
|||||||
.form-label {
|
.form-label {
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
color: var(--accents-5);
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--accents-6);
|
||||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-label:is(.dark *) {
|
||||||
|
color: var(--accents-3);
|
||||||
|
}
|
||||||
|
|
||||||
.glass-label, .modal-glass .form-label {
|
.glass-label, .modal-glass .form-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -1114,12 +1120,15 @@ input:-webkit-autofill,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:checked {
|
.checkbox:checked {
|
||||||
border-color: var(--foreground);
|
--tw-border-opacity: 1;
|
||||||
background-color: var(--foreground);
|
border-color: rgb(37 99 235 / var(--tw-border-opacity, 1));
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:hover {
|
.checkbox:hover {
|
||||||
border-color: var(--foreground);
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
|
||||||
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
|
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
|
||||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||||
@@ -1131,7 +1140,7 @@ input:-webkit-autofill,
|
|||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
--tw-ring-color: var(--accents-2);
|
--tw-ring-color: rgb(37 99 235 / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:disabled {
|
.checkbox:disabled {
|
||||||
@@ -1140,7 +1149,13 @@ input:-webkit-autofill,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:is(.dark *) {
|
.checkbox:is(.dark *) {
|
||||||
background-color: rgb(255 255 255 / 0.05);
|
border-color: rgb(255 255 255 / 0.3);
|
||||||
|
background-color: rgb(255 255 255 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox:hover:is(.dark *) {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(96 165 250 / var(--tw-border-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
@@ -1150,11 +1165,11 @@ input:-webkit-autofill,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:checked {
|
.checkbox:checked {
|
||||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .checkbox:checked {
|
.dark .checkbox:checked {
|
||||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='black' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
.card, .glass-card {
|
.card, .glass-card {
|
||||||
@@ -1233,6 +1248,7 @@ input:-webkit-autofill,
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1280,6 +1296,21 @@ input:-webkit-autofill,
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-select-dropdown.dropdown-up {
|
||||||
|
bottom: 100%;
|
||||||
|
top: auto;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
margin-top: 0px;
|
||||||
|
transform-origin: bottom;
|
||||||
|
--tw-translate-y: 0.5rem;
|
||||||
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-dropdown.dropdown-up.open {
|
||||||
|
--tw-translate-y: 0px;
|
||||||
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
|
}
|
||||||
|
|
||||||
/* Premium Control Pill & Segmented Switch */
|
/* Premium Control Pill & Segmented Switch */
|
||||||
|
|
||||||
.control-pill {
|
.control-pill {
|
||||||
@@ -1454,6 +1485,28 @@ input:-webkit-autofill,
|
|||||||
--tw-ring-color: var(--accents-3);
|
--tw-ring-color: var(--accents-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dropdown Bridge to prevent accidental closure on margin gaps */
|
||||||
|
|
||||||
|
.dropdown-bridge::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -1.25rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific Bridge expansion for Notification to make it more "sticky" */
|
||||||
|
|
||||||
|
#notification-dropdown.dropdown-bridge::before {
|
||||||
|
inset: -2rem -3rem;
|
||||||
|
/* Expand 32px top/bottom, 48px left/right */
|
||||||
|
top: -2.5rem;
|
||||||
|
/* Ensure it covers the gap to the button */
|
||||||
|
}
|
||||||
|
|
||||||
/* Glassmorphism Table */
|
/* Glassmorphism Table */
|
||||||
|
|
||||||
.table-container {
|
.table-container {
|
||||||
@@ -1571,14 +1624,6 @@ input:-webkit-autofill,
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
line-height: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -1673,6 +1718,10 @@ input:-webkit-autofill,
|
|||||||
top: -20%;
|
top: -20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-1 {
|
||||||
|
bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-6 {
|
.bottom-6 {
|
||||||
bottom: 1.5rem;
|
bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -1693,6 +1742,10 @@ input:-webkit-autofill,
|
|||||||
right: 0px;
|
right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-1 {
|
||||||
|
right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.right-2 {
|
.right-2 {
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -1705,16 +1758,12 @@ input:-webkit-autofill,
|
|||||||
right: 1rem;
|
right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-6 {
|
|
||||||
right: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-0 {
|
.top-0 {
|
||||||
top: 0px;
|
top: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-1\/2 {
|
.top-1 {
|
||||||
top: 50%;
|
top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-2 {
|
.top-2 {
|
||||||
@@ -1795,11 +1844,6 @@ input:-webkit-autofill,
|
|||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx-4 {
|
|
||||||
margin-left: 1rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@@ -2132,10 +2176,6 @@ input:-webkit-autofill,
|
|||||||
width: 5rem;
|
width: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-24 {
|
|
||||||
width: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-3 {
|
.w-3 {
|
||||||
width: 0.75rem;
|
width: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -2192,6 +2232,10 @@ input:-webkit-autofill,
|
|||||||
width: calc(100% - 1.5rem);
|
width: calc(100% - 1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-\[calc\(50\%-4px\)\] {
|
||||||
|
width: calc(50% - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
.w-auto {
|
.w-auto {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@@ -2225,10 +2269,6 @@ input:-webkit-autofill,
|
|||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.max-w-5xl {
|
|
||||||
max-width: 64rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.max-w-7xl {
|
.max-w-7xl {
|
||||||
max-width: 80rem;
|
max-width: 80rem;
|
||||||
}
|
}
|
||||||
@@ -2289,6 +2329,14 @@ input:-webkit-autofill,
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.origin-bottom-left {
|
||||||
|
transform-origin: bottom left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.origin-bottom-right {
|
||||||
|
transform-origin: bottom right;
|
||||||
|
}
|
||||||
|
|
||||||
.origin-right {
|
.origin-right {
|
||||||
transform-origin: right;
|
transform-origin: right;
|
||||||
}
|
}
|
||||||
@@ -2315,11 +2363,6 @@ input:-webkit-autofill,
|
|||||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
}
|
}
|
||||||
|
|
||||||
.-translate-y-1\/2 {
|
|
||||||
--tw-translate-y: -50%;
|
|
||||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
|
||||||
}
|
|
||||||
|
|
||||||
.translate-x-full {
|
.translate-x-full {
|
||||||
--tw-translate-x: 100%;
|
--tw-translate-x: 100%;
|
||||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
@@ -2520,10 +2563,10 @@ input:-webkit-autofill,
|
|||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
|
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
|
||||||
--tw-space-x-reverse: 0;
|
--tw-space-x-reverse: 0;
|
||||||
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
|
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
|
||||||
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
|
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
@@ -2608,6 +2651,10 @@ input:-webkit-autofill,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-visible {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.overflow-x-auto {
|
.overflow-x-auto {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -2697,9 +2744,9 @@ input:-webkit-autofill,
|
|||||||
border-bottom-right-radius: 0px;
|
border-bottom-right-radius: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rounded-t-md {
|
.rounded-t-xl {
|
||||||
border-top-left-radius: 0.375rem;
|
border-top-left-radius: 0.75rem;
|
||||||
border-top-right-radius: 0.375rem;
|
border-top-right-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
@@ -2722,6 +2769,10 @@ input:-webkit-autofill,
|
|||||||
border-bottom-width: 2px;
|
border-bottom-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-l {
|
||||||
|
border-left-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.border-l-0 {
|
.border-l-0 {
|
||||||
border-left-width: 0px;
|
border-left-width: 0px;
|
||||||
}
|
}
|
||||||
@@ -2822,6 +2873,10 @@ input:-webkit-autofill,
|
|||||||
border-color: rgb(255 255 255 / 0.2);
|
border-color: rgb(255 255 255 / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-white\/5 {
|
||||||
|
border-color: rgb(255 255 255 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
.\!bg-red-50\/50 {
|
.\!bg-red-50\/50 {
|
||||||
background-color: rgb(254 242 242 / 0.5) !important;
|
background-color: rgb(254 242 242 / 0.5) !important;
|
||||||
}
|
}
|
||||||
@@ -3067,6 +3122,11 @@ input:-webkit-autofill,
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.object-cover {
|
||||||
|
-o-object-fit: cover;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.\!p-0 {
|
.\!p-0 {
|
||||||
padding: 0px !important;
|
padding: 0px !important;
|
||||||
}
|
}
|
||||||
@@ -3234,6 +3294,14 @@ input:-webkit-autofill,
|
|||||||
padding-left: 0.75rem;
|
padding-left: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pl-4 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-6 {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pl-9 {
|
.pl-9 {
|
||||||
padding-left: 2.25rem;
|
padding-left: 2.25rem;
|
||||||
}
|
}
|
||||||
@@ -3250,6 +3318,10 @@ input:-webkit-autofill,
|
|||||||
padding-right: 0.75rem;
|
padding-right: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pr-6 {
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pr-8 {
|
.pr-8 {
|
||||||
padding-right: 2rem;
|
padding-right: 2rem;
|
||||||
}
|
}
|
||||||
@@ -3577,6 +3649,11 @@ input:-webkit-autofill,
|
|||||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-yellow-400 {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(250 204 21 / var(--tw-text-opacity, 1));
|
||||||
|
}
|
||||||
|
|
||||||
.text-yellow-500 {
|
.text-yellow-500 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(234 179 8 / var(--tw-text-opacity, 1));
|
color: rgb(234 179 8 / var(--tw-text-opacity, 1));
|
||||||
@@ -3611,10 +3688,6 @@ input:-webkit-autofill,
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opacity-60 {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opacity-70 {
|
.opacity-70 {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -3709,6 +3782,11 @@ input:-webkit-autofill,
|
|||||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backdrop-blur-2xl {
|
||||||
|
--tw-backdrop-blur: blur(40px);
|
||||||
|
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||||
|
}
|
||||||
|
|
||||||
.backdrop-blur-\[40px\] {
|
.backdrop-blur-\[40px\] {
|
||||||
--tw-backdrop-blur: blur(40px);
|
--tw-backdrop-blur: blur(40px);
|
||||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||||
@@ -3867,6 +3945,15 @@ div.swal2-popup {
|
|||||||
padding: 1.5rem !important;
|
padding: 1.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div:where(.swal2-container) {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:where(.swal2-popup).swal-wide {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 900px !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode background fix for glassmorphism */
|
/* Dark mode background fix for glassmorphism */
|
||||||
|
|
||||||
.dark div.swal2-popup {
|
.dark div.swal2-popup {
|
||||||
@@ -4203,6 +4290,66 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SweetAlert2 Premium Input Styles Override */
|
||||||
|
|
||||||
|
.swal2-premium-card .form-label {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accents-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swal2-premium-card .form-label:is(.dark *) {
|
||||||
|
color: var(--accents-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swal2-premium-card input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
.swal2-premium-card select,
|
||||||
|
.swal2-premium-card textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: var(--accents-2);
|
||||||
|
background-color: rgb(255 255 255 / 0.5);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
transition-property: all;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swal2-premium-card input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||||
|
.swal2-premium-card select:focus,
|
||||||
|
.swal2-premium-card textarea:focus {
|
||||||
|
border-color: var(--foreground);
|
||||||
|
background-color: rgb(255 255 255 / 0.8);
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
--tw-ring-color: var(--accents-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .swal2-premium-card input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
.dark .swal2-premium-card select,
|
||||||
|
.dark .swal2-premium-card textarea {
|
||||||
|
background-color: rgb(0 0 0 / 0.2);
|
||||||
|
border-color: var(--accents-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .swal2-premium-card input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||||
|
.dark .swal2-premium-card select:focus,
|
||||||
|
.dark .swal2-premium-card textarea:focus {
|
||||||
|
background-color: rgb(0 0 0 / 0.4);
|
||||||
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
.selection\:bg-accents-2 *::-moz-selection {
|
.selection\:bg-accents-2 *::-moz-selection {
|
||||||
background-color: var(--accents-2);
|
background-color: var(--accents-2);
|
||||||
}
|
}
|
||||||
@@ -4251,14 +4398,6 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder\:text-accents-3::-moz-placeholder {
|
|
||||||
color: var(--accents-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder\:text-accents-3::placeholder {
|
|
||||||
color: var(--accents-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.after\:absolute::after {
|
.after\:absolute::after {
|
||||||
content: var(--tw-content);
|
content: var(--tw-content);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -4310,6 +4449,10 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover\:border-accents-2:hover {
|
||||||
|
border-color: var(--accents-2);
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:border-foreground:hover {
|
.hover\:border-foreground:hover {
|
||||||
border-color: var(--foreground);
|
border-color: var(--foreground);
|
||||||
}
|
}
|
||||||
@@ -4349,6 +4492,11 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
|
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover\:bg-blue-700:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:bg-emerald-600:hover {
|
.hover\:bg-emerald-600:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(5 150 105 / var(--tw-bg-opacity, 1));
|
background-color: rgb(5 150 105 / var(--tw-bg-opacity, 1));
|
||||||
@@ -4998,6 +5146,10 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lg\:block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.lg\:h-\[calc\(100vh-8rem\)\] {
|
.lg\:h-\[calc\(100vh-8rem\)\] {
|
||||||
height: calc(100vh - 8rem);
|
height: calc(100vh - 8rem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,107 @@
|
|||||||
class SimpleDataTable {
|
/**
|
||||||
|
* Mivo Component: Datatable
|
||||||
|
* A simple, lightweight, client-side datatable.
|
||||||
|
*/
|
||||||
|
class DataTable {
|
||||||
constructor(tableSelector, options = {}) {
|
constructor(tableSelector, options = {}) {
|
||||||
this.table = document.querySelector(tableSelector);
|
this.table = document.querySelector(tableSelector);
|
||||||
if (!this.table) return;
|
if (!this.table) return;
|
||||||
|
|
||||||
this.tbody = this.table.querySelector('tbody');
|
this.tbody = this.table.querySelector('tbody');
|
||||||
this.rows = Array.from(this.tbody.querySelectorAll('tr'));
|
this.rows = Array.from(this.tbody.querySelectorAll('tr'));
|
||||||
this.originalRows = [...this.rows]; // Keep copy
|
this.originalRows = [...this.rows];
|
||||||
|
|
||||||
this.options = {
|
this.options = {
|
||||||
itemsPerPage: 10,
|
itemsPerPage: 10,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
pagination: true,
|
pagination: true,
|
||||||
filters: [], // Array of { index: number, label: string }
|
filters: [],
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.activeFilters = {}; // { columnIndex: value }
|
this.activeFilters = {};
|
||||||
this.filteredRows = [...this.originalRows];
|
this.filteredRows = [...this.originalRows];
|
||||||
|
|
||||||
// Wait for translations to load if i18n is used
|
// Listen for language changes via Mivo
|
||||||
if (window.i18n && window.i18n.ready) {
|
if (window.Mivo) {
|
||||||
window.i18n.ready.then(() => this.init());
|
window.Mivo.on('languageChanged', () => {
|
||||||
} else {
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for language change
|
|
||||||
window.addEventListener('languageChanged', () => {
|
|
||||||
this.reTranslate();
|
this.reTranslate();
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for I18n readiness if available
|
||||||
|
if (window.i18n && window.i18n.ready) {
|
||||||
|
window.i18n.ready.then(() => this.init());
|
||||||
|
} else {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reTranslate() {
|
reTranslate() {
|
||||||
// Update perPage label
|
const i18n = window.Mivo?.modules?.I18n || window.i18n;
|
||||||
const labels = this.wrapper.querySelectorAll('span.text-accents-5');
|
if (!i18n) return;
|
||||||
labels.forEach(label => {
|
|
||||||
if (label.textContent.includes('entries per page') || (window.i18n && label.textContent === window.i18n.t('common.table.entries_per_page'))) {
|
|
||||||
label.textContent = window.i18n ? window.i18n.t('common.table.entries_per_page') : 'entries per page';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update search placeholder
|
// Labels
|
||||||
const searchInput = this.wrapper.querySelector('input[type="text"]');
|
const labels = this.wrapper.querySelectorAll('.datatable-label');
|
||||||
if (searchInput) {
|
labels.forEach(l => l.textContent = i18n.t('common.table.entries_per_page'));
|
||||||
searchInput.placeholder = window.i18n ? window.i18n.t('common.table.search_placeholder') : 'Search...';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update All option
|
// Placeholder
|
||||||
const perPageSelect = this.wrapper.querySelector('select');
|
const searchInput = this.wrapper.querySelector('input.form-input-search');
|
||||||
|
if (searchInput) searchInput.placeholder = i18n.t('common.table.search_placeholder');
|
||||||
|
|
||||||
|
// "All" option
|
||||||
|
const perPageSelect = this.wrapper.querySelector('select.form-filter');
|
||||||
if (perPageSelect) {
|
if (perPageSelect) {
|
||||||
const allOption = Array.from(perPageSelect.options).find(opt => opt.value === "-1");
|
const allOption = Array.from(perPageSelect.options).find(opt => opt.value === "-1");
|
||||||
if (allOption) {
|
if (allOption) {
|
||||||
allOption.text = window.i18n ? window.i18n.t('common.table.all') : 'All';
|
allOption.text = i18n.t('common.table.all');
|
||||||
|
// Refresh custom select UI if needed
|
||||||
|
if (window.Mivo?.components?.Select) {
|
||||||
|
const instance = window.Mivo.components.Select.get(perPageSelect.id || '');
|
||||||
|
if (instance) instance.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Create Wrapper
|
const i18n = window.Mivo?.modules?.I18n || window.i18n;
|
||||||
|
|
||||||
|
// Wrapper
|
||||||
this.wrapper = document.createElement('div');
|
this.wrapper = document.createElement('div');
|
||||||
this.wrapper.className = 'datatable-wrapper space-y-4';
|
this.wrapper.className = 'datatable-wrapper space-y-4';
|
||||||
this.table.parentNode.insertBefore(this.wrapper, this.table);
|
this.table.parentNode.insertBefore(this.wrapper, this.table);
|
||||||
|
|
||||||
// Create Controls Header
|
// Header Controls
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'flex flex-col sm:flex-row justify-between items-center gap-4 mb-4';
|
header.className = 'flex flex-col sm:flex-row justify-between items-center gap-4 mb-4';
|
||||||
|
|
||||||
// Show Entries Wrapper
|
// Left Controls
|
||||||
const controlsLeft = document.createElement('div');
|
const controlsLeft = document.createElement('div');
|
||||||
controlsLeft.className = 'flex items-center gap-3 w-full sm:w-auto flex-wrap';
|
controlsLeft.className = 'flex items-center gap-3 w-full sm:w-auto flex-wrap';
|
||||||
|
|
||||||
|
// Per Page Select
|
||||||
const perPageSelect = document.createElement('select');
|
const perPageSelect = document.createElement('select');
|
||||||
perPageSelect.className = 'form-filter w-20';
|
perPageSelect.className = 'form-filter w-20';
|
||||||
|
// Add ID for CustomSelect registry if needed
|
||||||
|
perPageSelect.id = 'dt-perpage-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
|
||||||
[5, 10, 25, 50, 100].forEach(num => {
|
[5, 10, 25, 50, 100].forEach(num => {
|
||||||
const option = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
option.value = num;
|
opt.value = num;
|
||||||
option.text = num;
|
opt.text = num;
|
||||||
if (num === this.options.itemsPerPage) option.selected = true;
|
if (num === this.options.itemsPerPage) opt.selected = true;
|
||||||
perPageSelect.appendChild(option);
|
perPageSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// All option
|
// All Option
|
||||||
const allOption = document.createElement('option');
|
const allOpt = document.createElement('option');
|
||||||
allOption.value = -1;
|
allOpt.value = -1;
|
||||||
allOption.text = window.i18n ? window.i18n.t('common.table.all') : 'All';
|
allOpt.text = i18n ? i18n.t('common.table.all') : 'All';
|
||||||
perPageSelect.appendChild(allOption);
|
perPageSelect.appendChild(allOpt);
|
||||||
|
|
||||||
perPageSelect.addEventListener('change', (e) => {
|
perPageSelect.addEventListener('change', (e) => {
|
||||||
const val = parseInt(e.target.value);
|
const val = parseInt(e.target.value);
|
||||||
@@ -99,31 +112,30 @@ class SimpleDataTable {
|
|||||||
|
|
||||||
// Label
|
// Label
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'text-sm text-accents-5 whitespace-nowrap';
|
label.className = 'text-sm text-accents-5 whitespace-nowrap datatable-label';
|
||||||
label.textContent = window.i18n ? window.i18n.t('common.table.entries_per_page') : 'entries per page';
|
label.textContent = i18n ? i18n.t('common.table.entries_per_page') : 'entries per page';
|
||||||
|
|
||||||
controlsLeft.appendChild(perPageSelect);
|
controlsLeft.appendChild(perPageSelect);
|
||||||
controlsLeft.appendChild(label);
|
controlsLeft.appendChild(label);
|
||||||
|
|
||||||
// Initialize Filters if provided
|
// Init Custom Select using Mivo Component
|
||||||
|
if (window.Mivo?.components?.Select) {
|
||||||
|
new window.Mivo.components.Select(perPageSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
if (this.options.filters && this.options.filters.length > 0) {
|
if (this.options.filters && this.options.filters.length > 0) {
|
||||||
this.options.filters.forEach(filterConfig => {
|
this.options.filters.forEach(config => this.initFilter(config, controlsLeft));
|
||||||
this.initFilter(filterConfig, controlsLeft); // Append to Left Controls
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header.appendChild(controlsLeft);
|
header.appendChild(controlsLeft);
|
||||||
|
|
||||||
// Initialize CustomSelect if available (for perPage)
|
// Search
|
||||||
if (typeof CustomSelect !== 'undefined') {
|
|
||||||
new CustomSelect(perPageSelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search Input
|
|
||||||
if (this.options.searchable) {
|
if (this.options.searchable) {
|
||||||
const searchWrapper = document.createElement('div');
|
const searchWrapper = document.createElement('div');
|
||||||
searchWrapper.className = 'input-group sm:w-64 z-10';
|
searchWrapper.className = 'input-group sm:w-64 z-10';
|
||||||
const placeholder = window.i18n ? window.i18n.t('common.table.search_placeholder') : 'Search...';
|
const placeholder = i18n ? i18n.t('common.table.search_placeholder') : 'Search...';
|
||||||
|
|
||||||
searchWrapper.innerHTML = `
|
searchWrapper.innerHTML = `
|
||||||
<div class="input-icon">
|
<div class="input-icon">
|
||||||
<i data-lucide="search" class="w-4 h-4"></i>
|
<i data-lucide="search" class="w-4 h-4"></i>
|
||||||
@@ -137,21 +149,15 @@ class SimpleDataTable {
|
|||||||
|
|
||||||
this.wrapper.appendChild(header);
|
this.wrapper.appendChild(header);
|
||||||
|
|
||||||
// Move Table into Wrapper
|
// Table Container
|
||||||
// Move Table into Wrapper
|
|
||||||
this.tableWrapper = document.createElement('div');
|
this.tableWrapper = document.createElement('div');
|
||||||
this.tableWrapper.className = 'rounded-md border border-accents-2 overflow-x-auto bg-white/30 dark:bg-black/30 backdrop-blur-sm'; // overflow-x-auto for responsiveness
|
this.tableWrapper.className = 'rounded-md border border-accents-2 overflow-x-auto bg-white/30 dark:bg-black/30 backdrop-blur-sm';
|
||||||
this.tableWrapper.appendChild(this.table);
|
this.tableWrapper.appendChild(this.table);
|
||||||
this.wrapper.appendChild(this.tableWrapper);
|
this.wrapper.appendChild(this.tableWrapper);
|
||||||
|
|
||||||
// Render Icons for Header Controls
|
if (typeof lucide !== 'undefined') lucide.createIcons({ root: header });
|
||||||
if (typeof lucide !== 'undefined') {
|
|
||||||
lucide.createIcons({
|
|
||||||
root: header
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination Controls
|
// Pagination
|
||||||
if (this.options.pagination) {
|
if (this.options.pagination) {
|
||||||
this.paginationContainer = document.createElement('div');
|
this.paginationContainer = document.createElement('div');
|
||||||
this.paginationContainer.className = 'flex items-center justify-between px-2';
|
this.paginationContainer.className = 'flex items-center justify-between px-2';
|
||||||
@@ -162,29 +168,23 @@ class SimpleDataTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initFilter(config, container) {
|
initFilter(config, container) {
|
||||||
// config = { index: number, label: string }
|
|
||||||
const colIndex = config.index;
|
const colIndex = config.index;
|
||||||
|
|
||||||
// Get unique values
|
|
||||||
const values = new Set();
|
const values = new Set();
|
||||||
this.originalRows.forEach(row => {
|
this.originalRows.forEach(row => {
|
||||||
const cell = row.cells[colIndex];
|
const cell = row.cells[colIndex];
|
||||||
if (cell) {
|
if (cell) {
|
||||||
const text = cell.textContent.trim();
|
const text = cell.innerText.trim();
|
||||||
// Basic cleanup: remove extra whitespace
|
|
||||||
if(text && text !== '-' && text !== '') values.add(text);
|
if(text && text !== '-' && text !== '') values.add(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create Select
|
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
select.className = 'form-filter datatable-select'; // Use a different class to avoid auto-init by custom-select.js
|
select.className = 'form-filter datatable-select';
|
||||||
|
|
||||||
// Default Option
|
const defOpt = document.createElement('option');
|
||||||
const defaultOption = document.createElement('option');
|
defOpt.value = '';
|
||||||
defaultOption.value = '';
|
defOpt.text = config.label;
|
||||||
defaultOption.text = config.label;
|
select.appendChild(defOpt);
|
||||||
select.appendChild(defaultOption);
|
|
||||||
|
|
||||||
Array.from(values).sort().forEach(val => {
|
Array.from(values).sort().forEach(val => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
@@ -193,14 +193,11 @@ class SimpleDataTable {
|
|||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event Listener
|
|
||||||
select.addEventListener('change', (e) => {
|
select.addEventListener('change', (e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
if (val === '') {
|
if (val === '') delete this.activeFilters[colIndex];
|
||||||
delete this.activeFilters[colIndex];
|
else this.activeFilters[colIndex] = val;
|
||||||
} else {
|
|
||||||
this.activeFilters[colIndex] = val;
|
|
||||||
}
|
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
this.filterRows();
|
this.filterRows();
|
||||||
this.render();
|
this.render();
|
||||||
@@ -208,8 +205,8 @@ class SimpleDataTable {
|
|||||||
|
|
||||||
container.appendChild(select);
|
container.appendChild(select);
|
||||||
|
|
||||||
if (typeof CustomSelect !== 'undefined') {
|
if (window.Mivo?.components?.Select) {
|
||||||
new CustomSelect(select);
|
new window.Mivo.components.Select(select);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,23 +219,15 @@ class SimpleDataTable {
|
|||||||
|
|
||||||
filterRows() {
|
filterRows() {
|
||||||
this.filteredRows = this.originalRows.filter(row => {
|
this.filteredRows = this.originalRows.filter(row => {
|
||||||
// 1. Text Search
|
|
||||||
let matchesSearch = true;
|
let matchesSearch = true;
|
||||||
if (this.searchQuery) {
|
if (this.searchQuery) {
|
||||||
const text = row.textContent.toLowerCase();
|
matchesSearch = row.innerText.toLowerCase().includes(this.searchQuery);
|
||||||
matchesSearch = text.includes(this.searchQuery);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Column Filters
|
|
||||||
let matchesFilters = true;
|
let matchesFilters = true;
|
||||||
for (const [colIndex, filterValue] of Object.entries(this.activeFilters)) {
|
for (const [colIndex, filterValue] of Object.entries(this.activeFilters)) {
|
||||||
const cell = row.cells[colIndex];
|
const cell = row.cells[colIndex];
|
||||||
if (!cell) {
|
if (!cell || cell.innerText.trim() !== filterValue) {
|
||||||
matchesFilters = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Exact match (trimmed)
|
|
||||||
if (cell.textContent.trim() !== filterValue) {
|
|
||||||
matchesFilters = false;
|
matchesFilters = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -249,11 +238,10 @@ class SimpleDataTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// Calculate pagination
|
const i18n = window.Mivo?.modules?.I18n || window.i18n;
|
||||||
const totalItems = this.filteredRows.length;
|
const totalItems = this.filteredRows.length;
|
||||||
const totalPages = Math.ceil(totalItems / this.options.itemsPerPage);
|
const totalPages = Math.ceil(totalItems / this.options.itemsPerPage);
|
||||||
|
|
||||||
// Ensure current page is valid
|
|
||||||
if (this.currentPage > totalPages) this.currentPage = totalPages || 1;
|
if (this.currentPage > totalPages) this.currentPage = totalPages || 1;
|
||||||
if (this.currentPage < 1) this.currentPage = 1;
|
if (this.currentPage < 1) this.currentPage = 1;
|
||||||
|
|
||||||
@@ -261,14 +249,12 @@ class SimpleDataTable {
|
|||||||
const end = start + this.options.itemsPerPage;
|
const end = start + this.options.itemsPerPage;
|
||||||
const currentItems = this.filteredRows.slice(start, end);
|
const currentItems = this.filteredRows.slice(start, end);
|
||||||
|
|
||||||
// Clear and Re-append rows
|
|
||||||
this.tbody.innerHTML = '';
|
this.tbody.innerHTML = '';
|
||||||
if (currentItems.length > 0) {
|
if (currentItems.length > 0) {
|
||||||
currentItems.forEach(row => this.tbody.appendChild(row));
|
currentItems.forEach(row => this.tbody.appendChild(row));
|
||||||
} else {
|
} else {
|
||||||
// Empty State
|
|
||||||
const emptyRow = document.createElement('tr');
|
const emptyRow = document.createElement('tr');
|
||||||
const noMatchText = window.i18n ? window.i18n.t('common.table.no_match') : 'No match found.';
|
const noMatchText = i18n ? i18n.t('common.table.no_match') : 'No match found.';
|
||||||
emptyRow.innerHTML = `
|
emptyRow.innerHTML = `
|
||||||
<td colspan="100%" class="px-6 py-12 text-center text-accents-5">
|
<td colspan="100%" class="px-6 py-12 text-center text-accents-5">
|
||||||
<span class="text-sm">${noMatchText}</span>
|
<span class="text-sm">${noMatchText}</span>
|
||||||
@@ -277,27 +263,23 @@ class SimpleDataTable {
|
|||||||
this.tbody.appendChild(emptyRow);
|
this.tbody.appendChild(emptyRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Pagination
|
|
||||||
if (this.options.pagination) {
|
if (this.options.pagination) {
|
||||||
this.renderPagination(totalItems, totalPages, start + 1, Math.min(end, totalItems));
|
this.renderPagination(totalItems, totalPages, start + 1, Math.min(end, totalItems), i18n);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-initialize icons if Lucide is available
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
if (typeof lucide !== 'undefined') {
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPagination(totalItems, totalPages, start, end) {
|
renderPagination(totalItems, totalPages, start, end, i18n) {
|
||||||
if (totalItems === 0) {
|
if (totalItems === 0) {
|
||||||
this.paginationContainer.innerHTML = '';
|
this.paginationContainer.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showingText = window.i18n ? window.i18n.t('common.table.showing', {start, end, total: totalItems}) : `Showing ${start} to ${end} of ${totalItems}`;
|
const showingText = i18n ? i18n.t('common.table.showing', {start, end, total: totalItems}) : `Showing ${start} to ${end} of ${totalItems}`;
|
||||||
const previousText = window.i18n ? window.i18n.t('common.previous') : 'Previous';
|
const previousText = i18n ? i18n.t('common.previous') : 'Previous';
|
||||||
const nextText = window.i18n ? window.i18n.t('common.next') : 'Next';
|
const nextText = i18n ? i18n.t('common.next') : 'Next';
|
||||||
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: totalPages}) : `Page ${this.currentPage} of ${totalPages}`;
|
const pageText = i18n ? i18n.t('common.page_of', {current: this.currentPage, total: totalPages}) : `Page ${this.currentPage} of ${totalPages}`;
|
||||||
|
|
||||||
this.paginationContainer.innerHTML = `
|
this.paginationContainer.innerHTML = `
|
||||||
<div class="text-sm text-accents-5">
|
<div class="text-sm text-accents-5">
|
||||||
@@ -330,7 +312,11 @@ class SimpleDataTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export if using modules, otherwise it's global
|
// Register as Mivo Component
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (window.Mivo) {
|
||||||
module.exports = SimpleDataTable;
|
window.Mivo.registerComponent('Datatable', DataTable);
|
||||||
|
// Expose as window global for simpler backward compatibility if typically invoked via new SimpleDataTable()
|
||||||
|
window.SimpleDataTable = DataTable;
|
||||||
|
} else {
|
||||||
|
window.SimpleDataTable = DataTable;
|
||||||
}
|
}
|
||||||
252
public/assets/js/components/select.js
Normal file
252
public/assets/js/components/select.js
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* Mivo Component: Select
|
||||||
|
* Standardized Custom Select for Forms, Filters, and Navigation.
|
||||||
|
*/
|
||||||
|
class CustomSelect {
|
||||||
|
static instances = [];
|
||||||
|
|
||||||
|
static get(elementOrId) {
|
||||||
|
if (typeof elementOrId === 'string') {
|
||||||
|
return CustomSelect.instances.find(i => i.originalSelect.id === elementOrId);
|
||||||
|
}
|
||||||
|
return CustomSelect.instances.find(i => i.originalSelect === elementOrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(selectElement) {
|
||||||
|
if (selectElement.dataset.customSelectInitialized === 'true') return;
|
||||||
|
selectElement.dataset.customSelectInitialized = 'true';
|
||||||
|
|
||||||
|
this.originalSelect = selectElement;
|
||||||
|
this.originalSelect.style.display = 'none';
|
||||||
|
this.options = Array.from(this.originalSelect.options);
|
||||||
|
|
||||||
|
// Determine Variant
|
||||||
|
this.variant = this.originalSelect.dataset.variant || 'default';
|
||||||
|
if (this.originalSelect.classList.contains('form-filter')) this.variant = 'filter';
|
||||||
|
if (this.originalSelect.classList.contains('nav-select')) this.variant = 'nav';
|
||||||
|
|
||||||
|
this.wrapper = document.createElement('div');
|
||||||
|
this.buildWrapperClasses();
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
CustomSelect.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildWrapperClasses() {
|
||||||
|
let base = 'custom-select-wrapper relative active-select';
|
||||||
|
|
||||||
|
// Copy width classes
|
||||||
|
const widthClass = Array.from(this.originalSelect.classList).find(c => c.startsWith('w-') && c !== 'w-full');
|
||||||
|
const isFullWidth = this.originalSelect.classList.contains('w-full') ||
|
||||||
|
this.originalSelect.classList.contains('form-control') ||
|
||||||
|
this.originalSelect.classList.contains('form-input');
|
||||||
|
|
||||||
|
if (widthClass) base += ' ' + widthClass;
|
||||||
|
else if (isFullWidth) base += ' w-full';
|
||||||
|
else base += ' w-fit';
|
||||||
|
|
||||||
|
this.wrapper.className = base;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.trigger = document.createElement('div');
|
||||||
|
|
||||||
|
// Variant Styling
|
||||||
|
let triggerClass = 'flex items-center justify-between cursor-pointer pr-3 transition-all duration-200';
|
||||||
|
|
||||||
|
if (this.variant === 'filter') {
|
||||||
|
triggerClass += ' form-filter';
|
||||||
|
} else if (this.variant === 'nav') {
|
||||||
|
// New Nav variant for transparent/header usage
|
||||||
|
triggerClass += ' text-sm font-medium hover:bg-accents-2/50 rounded-lg px-2 py-1.5 border border-transparent hover:border-accents-2';
|
||||||
|
} else {
|
||||||
|
triggerClass += ' form-input';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inherit non-structural classes
|
||||||
|
const inherited = Array.from(this.originalSelect.classList)
|
||||||
|
.filter(c => !['custom-select', 'hidden', 'form-filter', 'form-input', 'w-full'].includes(c))
|
||||||
|
.join(' ');
|
||||||
|
if (inherited) triggerClass += ' ' + inherited;
|
||||||
|
|
||||||
|
this.trigger.className = triggerClass;
|
||||||
|
this.renderTrigger();
|
||||||
|
|
||||||
|
// Dropdown Menu
|
||||||
|
this.menu = document.createElement('div');
|
||||||
|
this.menu.className = 'custom-select-dropdown';
|
||||||
|
|
||||||
|
this.listContainer = document.createElement('div');
|
||||||
|
this.listContainer.className = 'overflow-y-auto flex-1 py-1 custom-scrollbar';
|
||||||
|
|
||||||
|
if (this.originalSelect.dataset.search === 'true') {
|
||||||
|
this.buildSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buildOptions();
|
||||||
|
|
||||||
|
this.menu.appendChild(this.listContainer);
|
||||||
|
this.wrapper.appendChild(this.trigger);
|
||||||
|
this.wrapper.appendChild(this.menu);
|
||||||
|
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect);
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.wrapper });
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTrigger() {
|
||||||
|
const option = this.originalSelect.options[this.originalSelect.selectedIndex];
|
||||||
|
const text = option ? option.text : '';
|
||||||
|
const icon = option?.dataset.icon;
|
||||||
|
const image = option?.dataset.image;
|
||||||
|
const flag = option?.dataset.flag;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (image) html += `<img src="${image}" class="w-5 h-5 mr-2 rounded-full object-cover">`;
|
||||||
|
else if (flag) html += `<span class="fi fi-${flag} mr-2 rounded-sm shadow-sm"></span>`;
|
||||||
|
else if (icon) html += `<i data-lucide="${icon}" class="w-4 h-4 mr-2 opacity-70"></i>`;
|
||||||
|
|
||||||
|
html += `<span class="truncate flex-1 text-left select-none">${text}</span>`;
|
||||||
|
html += `<i data-lucide="chevron-down" class="custom-select-icon w-4 h-4 ml-2 opacity-70 transition-transform duration-200"></i>`;
|
||||||
|
|
||||||
|
this.trigger.innerHTML = html;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.trigger });
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSearch() {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'p-2 bg-background z-10 border-b border-accents-2 rounded-t-xl sticky top-0';
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'w-full px-2 py-1.5 text-xs bg-accents-1 border border-accents-2 rounded-md focus:outline-none focus:ring-1 focus:ring-foreground transition-all';
|
||||||
|
input.placeholder = 'Search...';
|
||||||
|
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const term = e.target.value.toLowerCase();
|
||||||
|
Array.from(this.listContainer.children).forEach(el => {
|
||||||
|
el.style.display = el.textContent.toLowerCase().includes(term) ? 'flex' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('click', e => e.stopPropagation());
|
||||||
|
|
||||||
|
div.appendChild(input);
|
||||||
|
this.menu.appendChild(div);
|
||||||
|
this.searchInput = input;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOptions() {
|
||||||
|
this.listContainer.innerHTML = '';
|
||||||
|
this.options.forEach((opt, idx) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center gap-2 relative';
|
||||||
|
if (opt.selected) el.classList.add('bg-accents-1', 'font-medium');
|
||||||
|
|
||||||
|
// Icon/Image Logic
|
||||||
|
if (opt.dataset.image) el.innerHTML += `<img src="${opt.dataset.image}" class="w-5 h-5 rounded-full object-cover">`;
|
||||||
|
else if (opt.dataset.flag) el.innerHTML += `<span class="fi fi-${opt.dataset.flag} rounded-sm shadow-sm"></span>`;
|
||||||
|
else if (opt.dataset.icon) el.innerHTML += `<i data-lucide="${opt.dataset.icon}" class="w-4 h-4 opacity-70"></i>`;
|
||||||
|
|
||||||
|
el.innerHTML += `<span class="truncate">${opt.text}</span>`;
|
||||||
|
|
||||||
|
// Selected Checkmark
|
||||||
|
if (opt.selected) {
|
||||||
|
el.innerHTML += `<i data-lucide="check" class="w-3 h-3 ml-auto text-foreground absolute right-3"></i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('click', () => this.select(idx));
|
||||||
|
this.listContainer.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.trigger.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.toggle();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!this.wrapper.contains(e.target)) this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.menu.classList.contains('open') ? this.close() : this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
// Close others
|
||||||
|
CustomSelect.instances.forEach(i => i !== this && i.close());
|
||||||
|
|
||||||
|
// Smart Position
|
||||||
|
const rect = this.wrapper.getBoundingClientRect();
|
||||||
|
const menuHeight = 260; // Max-h-60 (240px) + padding + search if exists
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
|
||||||
|
// Reset positioning classes
|
||||||
|
this.menu.classList.remove(
|
||||||
|
'right-0', 'left-0',
|
||||||
|
'origin-top-right', 'origin-top-left',
|
||||||
|
'origin-bottom-right', 'origin-bottom-left',
|
||||||
|
'dropdown-up'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vertical check
|
||||||
|
const goUp = spaceBelow < menuHeight && spaceAbove > spaceBelow;
|
||||||
|
if (goUp) {
|
||||||
|
this.menu.classList.add('dropdown-up');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal check
|
||||||
|
const isRightAligned = window.innerWidth - rect.left < 250;
|
||||||
|
if (isRightAligned) {
|
||||||
|
this.menu.classList.add('right-0');
|
||||||
|
} else {
|
||||||
|
this.menu.classList.add('left-0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply correct Origin for animation
|
||||||
|
const originY = goUp ? 'bottom' : 'top';
|
||||||
|
const originX = isRightAligned ? 'right' : 'left';
|
||||||
|
this.menu.classList.add(`origin-${originY}-${originX}`);
|
||||||
|
|
||||||
|
this.menu.classList.add('open');
|
||||||
|
this.trigger.classList.add('ring-1', 'ring-foreground');
|
||||||
|
this.trigger.querySelector('.custom-select-icon')?.classList.add('rotate-180');
|
||||||
|
|
||||||
|
if (this.searchInput) setTimeout(() => this.searchInput.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.menu.classList.remove('open');
|
||||||
|
this.trigger.classList.remove('ring-1', 'ring-foreground');
|
||||||
|
this.trigger.querySelector('.custom-select-icon')?.classList.remove('rotate-180');
|
||||||
|
}
|
||||||
|
|
||||||
|
select(index) {
|
||||||
|
this.originalSelect.selectedIndex = index;
|
||||||
|
this.renderTrigger();
|
||||||
|
this.buildOptions(); // Rebuild to move checkmark
|
||||||
|
this.close();
|
||||||
|
this.originalSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.wrapper });
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.options = Array.from(this.originalSelect.options);
|
||||||
|
this.buildOptions();
|
||||||
|
this.renderTrigger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register to Mivo Framework
|
||||||
|
if (window.Mivo) {
|
||||||
|
window.Mivo.registerComponent('Select', CustomSelect);
|
||||||
|
|
||||||
|
// Auto-init on load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('select.custom-select').forEach(el => new CustomSelect(el));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
class CustomSelect {
|
|
||||||
static instances = [];
|
|
||||||
|
|
||||||
constructor(selectElement) {
|
|
||||||
if (selectElement.dataset.customSelectInitialized === 'true') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectElement.dataset.customSelectInitialized = 'true';
|
|
||||||
|
|
||||||
this.originalSelect = selectElement;
|
|
||||||
this.originalSelect.style.display = 'none';
|
|
||||||
this.options = Array.from(this.originalSelect.options);
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
this.wrapper = document.createElement('div');
|
|
||||||
|
|
||||||
// Standard classes
|
|
||||||
let wrapperClasses = 'custom-select-wrapper relative active-select';
|
|
||||||
|
|
||||||
// Intelligent Width:
|
|
||||||
// If original select expects full width, wrapper must be full width.
|
|
||||||
// Otherwise, use w-fit (Crucial for Right-Alignment in toolbars to work).
|
|
||||||
const widthClass = Array.from(this.originalSelect.classList).find(c => c.startsWith('w-') && c !== 'w-full');
|
|
||||||
const isFullWidth = this.originalSelect.classList.contains('w-full') ||
|
|
||||||
this.originalSelect.classList.contains('form-control') ||
|
|
||||||
this.originalSelect.classList.contains('form-input');
|
|
||||||
|
|
||||||
if (widthClass) {
|
|
||||||
wrapperClasses += ' ' + widthClass;
|
|
||||||
} else if (isFullWidth) {
|
|
||||||
wrapperClasses += ' w-full';
|
|
||||||
} else {
|
|
||||||
wrapperClasses += ' w-fit';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.wrapper.className = wrapperClasses;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
|
|
||||||
// Store instance
|
|
||||||
if (!CustomSelect.instances) CustomSelect.instances = [];
|
|
||||||
CustomSelect.instances.push(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Create Trigger
|
|
||||||
this.trigger = document.createElement('div');
|
|
||||||
|
|
||||||
const isFilter = this.originalSelect.classList.contains('form-filter');
|
|
||||||
const baseClass = isFilter ? 'form-filter' : 'form-input';
|
|
||||||
|
|
||||||
this.trigger.className = `${baseClass} flex items-center justify-between cursor-pointer pr-3`;
|
|
||||||
this.trigger.style.paddingLeft = '0.75rem';
|
|
||||||
|
|
||||||
this.trigger.innerHTML = `
|
|
||||||
<span class="custom-select-value truncate text-foreground flex-1 text-left">${this.originalSelect.options[this.originalSelect.selectedIndex].text}</span>
|
|
||||||
<div class="custom-select-icon flex-shrink-0 ml-2 transition-transform duration-200 transform">
|
|
||||||
<i data-lucide="chevron-down" class="w-4 h-4 text-foreground opacity-70"></i>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Inherit classes from original select (excluding custom-select marker)
|
|
||||||
if (this.originalSelect.classList.length > 0) {
|
|
||||||
const inheritedClasses = Array.from(this.originalSelect.classList)
|
|
||||||
.filter(c => c !== 'custom-select' && c !== 'hidden')
|
|
||||||
.join(' ');
|
|
||||||
if (inheritedClasses) {
|
|
||||||
this.trigger.className += ' ' + inheritedClasses;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final sanity check for full width
|
|
||||||
if (this.wrapper.classList.contains('w-full')) {
|
|
||||||
this.trigger.classList.add('w-full');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Options Menu Wrapper (No Scroll Here)
|
|
||||||
this.menu = document.createElement('div');
|
|
||||||
// Create Options Menu Wrapper (No Scroll Here)
|
|
||||||
// Create Options Menu Wrapper (No Scroll Here)
|
|
||||||
this.menu = document.createElement('div');
|
|
||||||
this.menu.className = 'custom-select-dropdown';
|
|
||||||
|
|
||||||
// Create Scrollable List Container
|
|
||||||
this.listContainer = document.createElement('div');
|
|
||||||
this.listContainer.className = 'overflow-y-auto flex-1 py-1 custom-scrollbar';
|
|
||||||
|
|
||||||
// Search Functionality
|
|
||||||
if (this.originalSelect.dataset.search === 'true') {
|
|
||||||
const searchContainer = document.createElement('div');
|
|
||||||
searchContainer.className = 'p-2 bg-background z-10 border-b border-accents-2 flex-shrink-0 rounded-t-md';
|
|
||||||
|
|
||||||
this.searchInput = document.createElement('input');
|
|
||||||
this.searchInput.type = 'text';
|
|
||||||
this.searchInput.className = 'w-full px-2 py-1 text-sm bg-accents-1 border border-accents-2 rounded focus:outline-none focus:ring-1 focus:ring-foreground';
|
|
||||||
this.searchInput.placeholder = 'Search...';
|
|
||||||
|
|
||||||
searchContainer.appendChild(this.searchInput);
|
|
||||||
this.menu.appendChild(searchContainer);
|
|
||||||
|
|
||||||
// Search Event
|
|
||||||
this.searchInput.addEventListener('input', (e) => {
|
|
||||||
const term = e.target.value.toLowerCase();
|
|
||||||
this.options.forEach((option, index) => {
|
|
||||||
const item = this.listContainer.querySelector(`[data-index="${index}"]`);
|
|
||||||
if (item) {
|
|
||||||
const text = option.text.toLowerCase();
|
|
||||||
item.style.display = text.includes(term) ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.searchInput.addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build Options
|
|
||||||
this.options.forEach((option, index) => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center justify-between whitespace-nowrap';
|
|
||||||
if(option.selected) item.classList.add('bg-accents-1', 'font-medium');
|
|
||||||
|
|
||||||
item.textContent = option.text;
|
|
||||||
item.dataset.value = option.value;
|
|
||||||
item.dataset.index = index;
|
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
this.select(index);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.listContainer.appendChild(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Append List to Menu
|
|
||||||
this.menu.appendChild(this.listContainer);
|
|
||||||
|
|
||||||
// Append to wrapper
|
|
||||||
this.wrapper.appendChild(this.trigger);
|
|
||||||
this.wrapper.appendChild(this.menu);
|
|
||||||
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect);
|
|
||||||
|
|
||||||
// Event Listeners
|
|
||||||
this.trigger.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.toggle();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!this.wrapper.contains(e.target)) {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof lucide !== 'undefined') {
|
|
||||||
lucide.createIcons({ root: this.trigger });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
if (!this.menu.classList.contains('open')) {
|
|
||||||
this.open();
|
|
||||||
} else {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
CustomSelect.instances.forEach(instance => {
|
|
||||||
if (instance !== this) instance.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Smart Positioning
|
|
||||||
const rect = this.wrapper.getBoundingClientRect();
|
|
||||||
const spaceRight = window.innerWidth - rect.left;
|
|
||||||
|
|
||||||
// Reset positioning classes
|
|
||||||
this.menu.classList.remove('right-0', 'origin-top-right', 'left-0', 'origin-top-left');
|
|
||||||
|
|
||||||
// Logic: Zone Check - If near right edge (< 300px), Force Right Align.
|
|
||||||
// Doing this purely based on coordinates prevents "Layout Jumping" caused by measuring content width.
|
|
||||||
if (spaceRight < 300) {
|
|
||||||
this.menu.classList.add('right-0', 'origin-top-right');
|
|
||||||
} else {
|
|
||||||
this.menu.classList.add('left-0', 'origin-top-left');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply visual open states
|
|
||||||
this.menu.classList.add('open');
|
|
||||||
|
|
||||||
this.trigger.classList.add('ring-1', 'ring-foreground');
|
|
||||||
const icon = this.trigger.querySelector('.custom-select-icon');
|
|
||||||
if(icon) icon.classList.add('rotate-180');
|
|
||||||
|
|
||||||
if (this.searchInput) {
|
|
||||||
setTimeout(() => this.searchInput.focus(), 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.menu.classList.remove('open');
|
|
||||||
|
|
||||||
this.trigger.classList.remove('ring-1', 'ring-foreground');
|
|
||||||
const icon = this.trigger.querySelector('.custom-select-icon');
|
|
||||||
if(icon) icon.classList.remove('rotate-180');
|
|
||||||
}
|
|
||||||
|
|
||||||
select(index) {
|
|
||||||
// Update Original Select
|
|
||||||
this.originalSelect.selectedIndex = index;
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
this.trigger.querySelector('.custom-select-value').textContent = this.options[index].text;
|
|
||||||
|
|
||||||
// Update Active State in List
|
|
||||||
Array.from(this.listContainer.children).forEach((child) => {
|
|
||||||
// Safe check
|
|
||||||
if (!child.dataset.index) return;
|
|
||||||
|
|
||||||
if (parseInt(child.dataset.index) === index) {
|
|
||||||
child.classList.add('bg-accents-1', 'font-medium');
|
|
||||||
} else {
|
|
||||||
child.classList.remove('bg-accents-1', 'font-medium');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.close();
|
|
||||||
this.originalSelect.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh() {
|
|
||||||
// Clear list items
|
|
||||||
this.listContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Re-read options
|
|
||||||
this.options = Array.from(this.originalSelect.options);
|
|
||||||
|
|
||||||
this.options.forEach((option, index) => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center justify-between whitespace-nowrap';
|
|
||||||
if(option.selected) item.classList.add('bg-accents-1', 'font-medium');
|
|
||||||
|
|
||||||
item.textContent = option.text;
|
|
||||||
item.dataset.value = option.value;
|
|
||||||
item.dataset.index = index;
|
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
this.select(index);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.listContainer.appendChild(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update Trigger
|
|
||||||
if (this.originalSelect.selectedIndex >= 0) {
|
|
||||||
this.trigger.querySelector('.custom-select-value').textContent = this.originalSelect.options[this.originalSelect.selectedIndex].text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
document.querySelectorAll('select.custom-select').forEach(el => new CustomSelect(el));
|
|
||||||
});
|
|
||||||
70
public/assets/js/mivo.js
Normal file
70
public/assets/js/mivo.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Mivo JS Core "The Kernel"
|
||||||
|
* Central management for Modules (Services) and Components (UI).
|
||||||
|
*/
|
||||||
|
class MivoCore {
|
||||||
|
constructor() {
|
||||||
|
this.modules = {};
|
||||||
|
this.components = {};
|
||||||
|
this.events = new EventTarget();
|
||||||
|
this.isReady = false;
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => this.init());
|
||||||
|
} else {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a Global Module (Service)
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Object} instance
|
||||||
|
*/
|
||||||
|
registerModule(name, instance) {
|
||||||
|
this.modules[name] = instance;
|
||||||
|
console.debug(`[Mivo] Module '${name}' registered.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a UI Component definition
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Class} classRef
|
||||||
|
*/
|
||||||
|
registerComponent(name, classRef) {
|
||||||
|
this.components[name] = classRef;
|
||||||
|
console.debug(`[Mivo] Component '${name}' registered.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to global events
|
||||||
|
* @param {string} eventName
|
||||||
|
* @param {function} callback
|
||||||
|
*/
|
||||||
|
on(eventName, callback) {
|
||||||
|
this.events.addEventListener(eventName, (e) => callback(e.detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit global events
|
||||||
|
* @param {string} eventName
|
||||||
|
* @param {any} data
|
||||||
|
*/
|
||||||
|
emit(eventName, data) {
|
||||||
|
this.events.dispatchEvent(new CustomEvent(eventName, { detail: data }));
|
||||||
|
console.debug(`[Mivo] Event emitted: ${eventName}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.isReady) return;
|
||||||
|
this.isReady = true;
|
||||||
|
console.log('[Mivo] Framework initialized.');
|
||||||
|
|
||||||
|
// Dispatch ready event for external scripts
|
||||||
|
this.emit('ready', { timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Singleton
|
||||||
|
window.Mivo = new MivoCore();
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Global Alert Helper for Mivo
|
* Mivo Module: Alert
|
||||||
* Provides a standardized way to trigger premium SweetAlert2 dialogs.
|
* Wraps SweetAlert2 and provides Toast notifications.
|
||||||
*/
|
*/
|
||||||
const Mivo = {
|
class AlertModule {
|
||||||
|
constructor() {
|
||||||
|
// No specific initialization needed for now
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a simple alert dialog.
|
* Show a simple alert dialog.
|
||||||
* @param {string} type - 'success', 'error', 'warning', 'info', 'question'
|
* @param {string} type - 'success', 'error', 'warning', 'info', 'question'
|
||||||
* @param {string} title - The title of the alert
|
* @param {string} title
|
||||||
* @param {string} message - The body text/HTML
|
* @param {string} message
|
||||||
* @returns {Promise}
|
|
||||||
*/
|
*/
|
||||||
alert: function(type, title, message = '') {
|
fire(type, title, message = '', options = {}) {
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
'success': { icon: 'check-circle-2', color: 'text-success' },
|
'success': { icon: 'check-circle-2', color: 'text-success' },
|
||||||
'error': { icon: 'x-circle', color: 'text-error' },
|
'error': { icon: 'x-circle', color: 'text-error' },
|
||||||
@@ -21,7 +24,8 @@ const Mivo = {
|
|||||||
|
|
||||||
const config = typeMap[type] || typeMap['info'];
|
const config = typeMap[type] || typeMap['info'];
|
||||||
|
|
||||||
return Swal.fire({
|
// Default Config
|
||||||
|
const defaultConfig = {
|
||||||
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`,
|
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`,
|
||||||
title: title,
|
title: title,
|
||||||
html: message,
|
html: message,
|
||||||
@@ -33,21 +37,32 @@ const Mivo = {
|
|||||||
},
|
},
|
||||||
buttonsStyling: false,
|
buttonsStyling: false,
|
||||||
heightAuto: false,
|
heightAuto: false,
|
||||||
|
scrollbarPadding: false,
|
||||||
didOpen: () => {
|
didOpen: () => {
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
},
|
|
||||||
|
// Merge user options with default config
|
||||||
|
// Special deep merge for customClass if provided to avoid wiping defaults completely?
|
||||||
|
// simple spread for now, user should know what they are doing if overriding classes.
|
||||||
|
// Actually, let's smart merge customClass
|
||||||
|
if (options.customClass) {
|
||||||
|
options.customClass = {
|
||||||
|
...defaultConfig.customClass,
|
||||||
|
...options.customClass
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalConfig = { ...defaultConfig, ...options };
|
||||||
|
|
||||||
|
return Swal.fire(finalConfig);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a confirmation dialog.
|
* Show a confirmation dialog.
|
||||||
* @param {string} title - The title of the confirmation
|
|
||||||
* @param {string} message - The body text/HTML
|
|
||||||
* @param {string} confirmText - Text for the confirm button
|
|
||||||
* @param {string} cancelText - Text for the cancel button
|
|
||||||
* @returns {Promise} Resolves if confirmed, rejects if cancelled
|
|
||||||
*/
|
*/
|
||||||
confirm: function(title, message = '', confirmText = 'Yes, Proceed', cancelText = 'Cancel') {
|
confirm(title, message = '', confirmText = 'Yes, Proceed', cancelText = 'Cancel') {
|
||||||
return Swal.fire({
|
return Swal.fire({
|
||||||
iconHtml: `<i data-lucide="help-circle" class="w-12 h-12 text-question"></i>`,
|
iconHtml: `<i data-lucide="help-circle" class="w-12 h-12 text-question"></i>`,
|
||||||
title: title,
|
title: title,
|
||||||
@@ -63,20 +78,17 @@ const Mivo = {
|
|||||||
buttonsStyling: false,
|
buttonsStyling: false,
|
||||||
reverseButtons: true,
|
reverseButtons: true,
|
||||||
heightAuto: false,
|
heightAuto: false,
|
||||||
|
scrollbarPadding: false,
|
||||||
didOpen: () => {
|
didOpen: () => {
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
}).then(result => result.isConfirmed);
|
}).then(result => result.isConfirmed);
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a premium stacking toast.
|
* Show a stacking toast notification.
|
||||||
* @param {string} type - 'success', 'error', 'warning', 'info'
|
|
||||||
* @param {string} title - Title
|
|
||||||
* @param {string} message - Body text
|
|
||||||
* @param {number} duration - ms before auto-close
|
|
||||||
*/
|
*/
|
||||||
toast: function(type, title, message = '', duration = 5000) {
|
toast(type, title, message = '', duration = 5000) {
|
||||||
let container = document.getElementById('mivo-toast-container');
|
let container = document.getElementById('mivo-toast-container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
@@ -124,7 +136,7 @@ const Mivo = {
|
|||||||
|
|
||||||
toast.querySelector('.mivo-toast-close').addEventListener('click', closeToast);
|
toast.querySelector('.mivo-toast-close').addEventListener('click', closeToast);
|
||||||
|
|
||||||
// Auto-close with progress bar
|
// Progress Bar
|
||||||
const progress = toast.querySelector('.mivo-toast-progress');
|
const progress = toast.querySelector('.mivo-toast-progress');
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
@@ -142,7 +154,73 @@ const Mivo = {
|
|||||||
|
|
||||||
requestAnimationFrame(updateProgress);
|
requestAnimationFrame(updateProgress);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Also expose as global shortcuts if needed
|
/**
|
||||||
window.Mivo = Mivo;
|
* Modal Form Logic
|
||||||
|
*/
|
||||||
|
form(title, html, confirmText = 'Save', preConfirmFn = null, didOpenFn = null, customClass = '') {
|
||||||
|
return Swal.fire({
|
||||||
|
title: title,
|
||||||
|
html: html,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: confirmText,
|
||||||
|
cancelButtonText: window.i18n ? window.i18n.t('common.cancel') : 'Cancel',
|
||||||
|
customClass: {
|
||||||
|
popup: `swal2-premium-card ${customClass}`,
|
||||||
|
title: 'text-xl font-bold text-foreground mb-4',
|
||||||
|
htmlContainer: 'text-left overflow-visible', // overflow-visible for selects
|
||||||
|
confirmButton: 'btn btn-primary px-6',
|
||||||
|
cancelButton: 'btn btn-secondary px-6',
|
||||||
|
actions: 'gap-3'
|
||||||
|
},
|
||||||
|
buttonsStyling: false,
|
||||||
|
reverseButtons: true,
|
||||||
|
heightAuto: false,
|
||||||
|
scrollbarPadding: false,
|
||||||
|
didOpen: () => {
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
|
||||||
|
const popup = Swal.getHtmlContainer();
|
||||||
|
|
||||||
|
if (didOpenFn && typeof didOpenFn === 'function') {
|
||||||
|
didOpenFn(popup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Custom Selects using Mivo Component if available
|
||||||
|
if (popup && window.Mivo && window.Mivo.components.Select) {
|
||||||
|
const selects = popup.querySelectorAll('select');
|
||||||
|
selects.forEach(el => {
|
||||||
|
if (!el.classList.contains('custom-select')) {
|
||||||
|
el.classList.add('custom-select');
|
||||||
|
}
|
||||||
|
new window.Mivo.components.Select(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstInput = popup.querySelector('input:not([type="hidden"]), textarea');
|
||||||
|
if (firstInput) firstInput.focus();
|
||||||
|
},
|
||||||
|
preConfirm: () => {
|
||||||
|
return preConfirmFn ? preConfirmFn() : true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Module
|
||||||
|
if (window.Mivo) {
|
||||||
|
const alertModule = new AlertModule();
|
||||||
|
window.Mivo.registerModule('Alert', alertModule);
|
||||||
|
|
||||||
|
// Add Aliases to Mivo object for easy access (Mivo.alert(...))
|
||||||
|
// This maintains backward compatibility with the old object literal style
|
||||||
|
window.Mivo.alert = (type, title, msg, opts) => alertModule.fire(type, title, msg, opts);
|
||||||
|
window.Mivo.confirm = (t, m, c, cx) => alertModule.confirm(t, m, c, cx);
|
||||||
|
window.Mivo.toast = (t, ti, m, d) => alertModule.toast(t, ti, m, d);
|
||||||
|
// Aliases for Mivo.modal call style
|
||||||
|
window.Mivo.modal = {
|
||||||
|
form: (t, h, c, p, o, cc) => alertModule.form(t, h, c, p, o, cc)
|
||||||
|
};
|
||||||
|
// Wait, modal was nested. Let's expose the form method carefully or keep it on the module.
|
||||||
|
// Let's just expose the module mostly.
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Mivo Module: I18n
|
||||||
|
* Internationalization support.
|
||||||
|
*/
|
||||||
class I18n {
|
class I18n {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentLang = localStorage.getItem('mivo_lang') || 'en';
|
this.currentLang = localStorage.getItem('mivo_lang') || 'en';
|
||||||
this.translations = {};
|
this.translations = {};
|
||||||
this.isLoaded = false;
|
this.isLoaded = false;
|
||||||
// The ready promise resolves after the first language load
|
|
||||||
|
// Expose global helper for legacy onclicks
|
||||||
|
window.changeLanguage = (lang) => this.loadLanguage(lang);
|
||||||
|
|
||||||
this.ready = this.init();
|
this.ready = this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +21,6 @@ class I18n {
|
|||||||
|
|
||||||
async loadLanguage(lang) {
|
async loadLanguage(lang) {
|
||||||
try {
|
try {
|
||||||
// Add cache busting to ensure fresh translation files
|
|
||||||
const cacheBuster = Date.now();
|
const cacheBuster = Date.now();
|
||||||
const response = await fetch(`/lang/${lang}.json?v=${cacheBuster}`);
|
const response = await fetch(`/lang/${lang}.json?v=${cacheBuster}`);
|
||||||
if (!response.ok) throw new Error(`Failed to load language: ${lang}`);
|
if (!response.ok) throw new Error(`Failed to load language: ${lang}`);
|
||||||
@@ -22,12 +28,17 @@ class I18n {
|
|||||||
this.translations = await response.json();
|
this.translations = await response.json();
|
||||||
this.currentLang = lang;
|
this.currentLang = lang;
|
||||||
localStorage.setItem('mivo_lang', lang);
|
localStorage.setItem('mivo_lang', lang);
|
||||||
|
|
||||||
this.applyTranslations();
|
this.applyTranslations();
|
||||||
|
|
||||||
// Dispatch event for other components
|
// Dispatch via Mivo Event Bus
|
||||||
|
if (window.Mivo) {
|
||||||
|
window.Mivo.emit('languageChanged', { lang });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy Event for compatibility
|
||||||
window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } }));
|
window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } }));
|
||||||
|
|
||||||
// Update html lang attribute
|
|
||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('I18n Error:', error);
|
console.error('I18n Error:', error);
|
||||||
@@ -43,16 +54,9 @@ class I18n {
|
|||||||
if (element.tagName === 'INPUT' && element.getAttribute('placeholder')) {
|
if (element.tagName === 'INPUT' && element.getAttribute('placeholder')) {
|
||||||
element.placeholder = translation;
|
element.placeholder = translation;
|
||||||
} else {
|
} else {
|
||||||
// Check if element has child nodes that are not text (e.g. icons)
|
|
||||||
// If simple text, just replace
|
|
||||||
// If complex, try to preserve icon?
|
|
||||||
// For now, let's assume strictly text replacement or user wraps text in span
|
|
||||||
// Better approach: Look for a text node?
|
|
||||||
// Simplest for now: innerText
|
|
||||||
element.textContent = translation;
|
element.textContent = translation;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Log missing translation for developers (only if fully loaded)
|
|
||||||
if (this.isLoaded) {
|
if (this.isLoaded) {
|
||||||
console.warn(`[i18n] Missing translation for key: "${key}" (lang: ${this.currentLang})`);
|
console.warn(`[i18n] Missing translation for key: "${key}" (lang: ${this.currentLang})`);
|
||||||
}
|
}
|
||||||
@@ -68,13 +72,10 @@ class I18n {
|
|||||||
let text = this.getNestedValue(this.translations, key);
|
let text = this.getNestedValue(this.translations, key);
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
if (this.isLoaded) {
|
if (this.isLoaded) console.warn(`[i18n] Missing translation for key: "${key}"`);
|
||||||
console.warn(`[i18n] Missing translation for key: "${key}" (lang: ${this.currentLang})`);
|
text = key;
|
||||||
}
|
|
||||||
text = key; // Fallback to key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple interpolation: {key}
|
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.keys(params).forEach(param => {
|
Object.keys(params).forEach(param => {
|
||||||
text = text.replace(new RegExp(`{${param}}`, 'g'), params[param]);
|
text = text.replace(new RegExp(`{${param}}`, 'g'), params[param]);
|
||||||
@@ -84,10 +85,12 @@ class I18n {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Register Module
|
||||||
window.i18n = new I18n();
|
if (window.Mivo) {
|
||||||
|
window.Mivo.registerModule('I18n', new I18n());
|
||||||
// Global helper
|
// Alias for global usage if needed
|
||||||
function changeLanguage(lang) {
|
window.i18n = window.Mivo.modules.I18n;
|
||||||
window.i18n.loadLanguage(lang);
|
} else {
|
||||||
|
// Fallback if Mivo not loaded
|
||||||
|
window.i18n = new I18n();
|
||||||
}
|
}
|
||||||
112
public/assets/js/modules/updater.js
Normal file
112
public/assets/js/modules/updater.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Mivo Module: Updater
|
||||||
|
* Handles version checking and update notifications.
|
||||||
|
*/
|
||||||
|
class UpdaterModule {
|
||||||
|
constructor() {
|
||||||
|
this.repo = 'dyzulk/mivo';
|
||||||
|
this.cacheKey = 'mivo_update_data';
|
||||||
|
this.ttl = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
// Wait for Mivo core to be ready
|
||||||
|
if (window.Mivo) {
|
||||||
|
window.Mivo.on('ready', () => this.init());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const updateData = this.getCache();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (updateData && (now - updateData.timestamp < this.ttl)) {
|
||||||
|
this.checkUpdate(updateData.version, updateData.url);
|
||||||
|
} else {
|
||||||
|
await this.fetchLatest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCache() {
|
||||||
|
const data = localStorage.getItem(this.cacheKey);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCache(version, url) {
|
||||||
|
const data = {
|
||||||
|
version: version,
|
||||||
|
url: url,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem(this.cacheKey, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchLatest() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.github.com/repos/${this.repo}/releases/latest`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch version');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const version = data.tag_name; // e.g., v1.1.0
|
||||||
|
const url = data.html_url;
|
||||||
|
|
||||||
|
this.setCache(version, url);
|
||||||
|
this.checkUpdate(version, url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mivo] Update check failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkUpdate(latestVersion, url) {
|
||||||
|
if (!window.currentVersion) return;
|
||||||
|
|
||||||
|
// Simple version comparison (removing 'v' prefix if exists)
|
||||||
|
const current = window.currentVersion.replace('v', '');
|
||||||
|
const latest = latestVersion.replace('v', '');
|
||||||
|
|
||||||
|
if (this.isNewer(current, latest)) {
|
||||||
|
this.showNotification(latestVersion, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isNewer(current, latest) {
|
||||||
|
const cParts = current.split('.').map(Number);
|
||||||
|
const lParts = latest.split('.').map(Number);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(cParts.length, lParts.length); i++) {
|
||||||
|
const c = cParts[i] || 0;
|
||||||
|
const l = lParts[i] || 0;
|
||||||
|
if (l > c) return true;
|
||||||
|
if (l < c) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(version, url) {
|
||||||
|
const badge = document.getElementById('update-badge');
|
||||||
|
const content = document.getElementById('notification-content');
|
||||||
|
|
||||||
|
if (badge) badge.classList.remove('hidden');
|
||||||
|
if (content) {
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-500/10 rounded-full">
|
||||||
|
<i data-lucide="rocket" class="w-6 h-6 text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="font-bold text-foreground">New Version Available!</p>
|
||||||
|
<p class="text-xs text-accents-4">Version <span class="font-mono">${version}</span> is now available.</p>
|
||||||
|
</div>
|
||||||
|
<a href="${url}" target="_blank" class="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold transition-colors flex items-center justify-center gap-2">
|
||||||
|
<i data-lucide="download" class="w-3 h-3"></i>
|
||||||
|
<span>Download Update</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Module
|
||||||
|
if (window.Mivo) {
|
||||||
|
window.Mivo.registerModule('Updater', new UpdaterModule());
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const checkBtn = document.getElementById('check-interface-btn');
|
|
||||||
const ifaceSelect = document.getElementById('iface');
|
|
||||||
|
|
||||||
if (checkBtn && ifaceSelect) {
|
|
||||||
checkBtn.addEventListener('click', async () => {
|
|
||||||
const originalText = checkBtn.innerHTML;
|
|
||||||
checkBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 mr-2 animate-spin"></i> Checking...';
|
|
||||||
checkBtn.disabled = true;
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
||||||
|
|
||||||
// Collect Data
|
|
||||||
const ip = document.querySelector('input[name="ipmik"]').value;
|
|
||||||
const user = document.querySelector('input[name="usermik"]').value;
|
|
||||||
const pass = document.querySelector('input[name="passmik"]').value;
|
|
||||||
const idInput = document.querySelector('input[name="id"]');
|
|
||||||
const id = idInput ? idInput.value : null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/router/interfaces', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ip, user, password: pass, id })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.interfaces) {
|
|
||||||
// Update Select
|
|
||||||
ifaceSelect.innerHTML = ''; // Clear
|
|
||||||
|
|
||||||
data.interfaces.forEach(iface => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = iface;
|
|
||||||
opt.textContent = iface;
|
|
||||||
if (iface === 'ether1') opt.selected = true; // Default preferred?
|
|
||||||
ifaceSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh Custom Select
|
|
||||||
if (typeof CustomSelect !== 'undefined' && CustomSelect.instances) {
|
|
||||||
const instance = CustomSelect.instances.find(i => i.originalSelect.id === 'iface');
|
|
||||||
if (instance) instance.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success
|
|
||||||
checkBtn.innerHTML = '<i data-lucide="check" class="w-4 h-4 mr-2"></i> Interfaces Loaded';
|
|
||||||
setTimeout(() => {
|
|
||||||
checkBtn.innerHTML = originalText;
|
|
||||||
checkBtn.disabled = false;
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
alert('Error: ' + (data.error || 'Failed to fetch interfaces'));
|
|
||||||
checkBtn.innerHTML = originalText;
|
|
||||||
checkBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert('Connection Error');
|
|
||||||
checkBtn.innerHTML = originalText;
|
|
||||||
checkBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session Name Auto-Conversion
|
|
||||||
const sessInput = document.querySelector('input[name="sessname"]');
|
|
||||||
const sessPreview = document.getElementById('sessname-preview');
|
|
||||||
|
|
||||||
if (sessInput) {
|
|
||||||
// Initial set if editing
|
|
||||||
if(sessPreview) sessPreview.textContent = sessInput.value;
|
|
||||||
|
|
||||||
sessInput.addEventListener('input', (e) => {
|
|
||||||
let val = e.target.value;
|
|
||||||
// 1. Lowercase
|
|
||||||
val = val.toLowerCase();
|
|
||||||
// 2. Space -> Dash
|
|
||||||
val = val.replace(/\s+/g, '-');
|
|
||||||
// 3. Remove non-alphanumeric (except dash)
|
|
||||||
val = val.replace(/[^a-z0-9-]/g, '');
|
|
||||||
// 4. No double dashes
|
|
||||||
val = val.replace(/-+/g, '-');
|
|
||||||
|
|
||||||
// Write back to input (Auto Convert)
|
|
||||||
e.target.value = val;
|
|
||||||
|
|
||||||
// Update Preview
|
|
||||||
if (sessPreview) {
|
|
||||||
sessPreview.textContent = val || '...';
|
|
||||||
sessPreview.className = val ? 'font-mono text-primary font-bold' : 'font-mono text-accents-4';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"none": "none",
|
"none": "none",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
}
|
},
|
||||||
|
"warning": "Warning"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"subtitle": "A modern, lightweight MikroTik Hotspot Manager built for performance and simplicity.",
|
"subtitle": "A modern, lightweight MikroTik Hotspot Manager built for performance and simplicity.",
|
||||||
@@ -137,7 +138,26 @@
|
|||||||
"origin": "Origin",
|
"origin": "Origin",
|
||||||
"methods": "Allowed Methods",
|
"methods": "Allowed Methods",
|
||||||
"headers": "Allowed Headers",
|
"headers": "Allowed Headers",
|
||||||
"max_age": "Max Age (seconds)"
|
"max_age": "Max Age (seconds)",
|
||||||
|
"cpu_warning": "Low values (< 5s) may increase CPU usage on older routers.",
|
||||||
|
"back": "Back to Settings"
|
||||||
|
},
|
||||||
|
"routers": {
|
||||||
|
"edit_router_title": "Edit Router",
|
||||||
|
"add_router_title": "Add Router",
|
||||||
|
"connect_desc": "Connect Mikhmon to your RouterOS device.",
|
||||||
|
"session_settings": "Session Settings",
|
||||||
|
"unique_id": "Unique ID. Preview:",
|
||||||
|
"show_quick_access": "Show in Quick Access (Home Page)",
|
||||||
|
"connection_details": "Connection Details",
|
||||||
|
"password_hint": "Leave empty to keep existing password.",
|
||||||
|
"hotspot_info": "Hotspot Information",
|
||||||
|
"dns_name": "DNS Name",
|
||||||
|
"traffic_interface": "Traffic Interface",
|
||||||
|
"check_btn": "Check",
|
||||||
|
"currency": "Currency",
|
||||||
|
"auto_reload": "Auto Reload (Sec)",
|
||||||
|
"save_connect": "Save & Connect"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Welcome back, please sign in to continue.",
|
"welcome": "Welcome back, please sign in to continue.",
|
||||||
@@ -244,9 +264,9 @@
|
|||||||
"host": "Host Name"
|
"host": "Host Name"
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
"title": "Hotspot Cookies",
|
"title": "Hotspot Users",
|
||||||
"subtitle": "Active authentication cookies for:",
|
"subtitle": "Manage users and vouchers",
|
||||||
"user": "User",
|
"name": "Name",
|
||||||
"mac": "MAC Address",
|
"mac": "MAC Address",
|
||||||
"ip": "IP Address",
|
"ip": "IP Address",
|
||||||
"expires": "Expires In",
|
"expires": "Expires In",
|
||||||
@@ -316,6 +336,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot_users": {
|
"hotspot_users": {
|
||||||
|
"add_user": "Add User",
|
||||||
|
"edit_user": "Edit User",
|
||||||
|
"title": "Hotspot Users",
|
||||||
|
"subtitle": "Manage users and vouchers",
|
||||||
|
"name": "Name",
|
||||||
|
"profile": "Profile",
|
||||||
|
"uptime_limit": "Uptime / Limit",
|
||||||
|
"bytes_in_out": "Bytes In/Out",
|
||||||
|
"comment": "Comment",
|
||||||
|
"no_users_selected": "No users selected.",
|
||||||
"form": {
|
"form": {
|
||||||
"add_title": "Add User",
|
"add_title": "Add User",
|
||||||
"edit_title": "Edit User",
|
"edit_title": "Edit User",
|
||||||
@@ -540,5 +570,38 @@
|
|||||||
"cors_rule_updated_desc": "Changes to CORS rule for {origin} have been saved.",
|
"cors_rule_updated_desc": "Changes to CORS rule for {origin} have been saved.",
|
||||||
"cors_rule_deleted": "CORS Rule Deleted",
|
"cors_rule_deleted": "CORS Rule Deleted",
|
||||||
"cors_rule_deleted_desc": "The CORS rule has been removed."
|
"cors_rule_deleted_desc": "The CORS rule has been removed."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"check_title": "Check Voucher Status",
|
||||||
|
"check_desc": "Monitor your data usage and voucher validity in real-time without needing to re-login.",
|
||||||
|
"voucher_code_label": "Voucher Code",
|
||||||
|
"voucher_code_placeholder": "Ex: QWASZX",
|
||||||
|
"code_placeholder": "Ex: QWASZX",
|
||||||
|
"check_now": "Check Now",
|
||||||
|
"details_title": "Voucher Details",
|
||||||
|
"code": "Voucher Code",
|
||||||
|
"data_remaining": "Data Remaining",
|
||||||
|
"used": "Used",
|
||||||
|
"package": "Package",
|
||||||
|
"validity": "Validity",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"expires": "Expires",
|
||||||
|
"not_found_title": "Voucher Not Found",
|
||||||
|
"not_found_desc": "The voucher code you entered does not exist.",
|
||||||
|
"try_again": "Try Again"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"404_title": "Page Not Found",
|
||||||
|
"404_desc": "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.",
|
||||||
|
"403_title": "Access Denied",
|
||||||
|
"403_desc": "You do not have permission to access this resource.",
|
||||||
|
"500_title": "Server Error",
|
||||||
|
"500_desc": "Something went wrong on our end. Please try again later.",
|
||||||
|
"503_title": "Service Unavailable",
|
||||||
|
"503_desc": "The server is currently unable to handle the request due to maintenance or overload.",
|
||||||
|
"router_not_found_title": "Router Not Found",
|
||||||
|
"router_not_found_desc": "The router session you are trying to access does not exist or has been removed.",
|
||||||
|
"return_home": "Return Home",
|
||||||
|
"go_back": "Go Back"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"save_changes": "Simpan Perubahan",
|
"save_changes": "Simpan Perubahan",
|
||||||
"please_wait": "Mohon tunggu...",
|
"please_wait": "Mohon tunggu...",
|
||||||
"none": "tidak ada"
|
"none": "tidak ada"
|
||||||
}
|
},
|
||||||
|
"warning": "Peringatan"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"subtitle": "Hotspot Manager MikroTik modern dan ringan yang dirancang untuk performa dan kemudahan.",
|
"subtitle": "Hotspot Manager MikroTik modern dan ringan yang dirancang untuk performa dan kemudahan.",
|
||||||
@@ -137,7 +138,26 @@
|
|||||||
"origin": "Origin",
|
"origin": "Origin",
|
||||||
"methods": "Metode Diizinkan",
|
"methods": "Metode Diizinkan",
|
||||||
"headers": "Header Diizinkan",
|
"headers": "Header Diizinkan",
|
||||||
"max_age": "Max Age (detik)"
|
"max_age": "Max Age (detik)",
|
||||||
|
"cpu_warning": "Nilai rendah (< 5s) dapat meningkatkan beban CPU pada router lama.",
|
||||||
|
"back": "Kembali ke Pengaturan"
|
||||||
|
},
|
||||||
|
"routers": {
|
||||||
|
"edit_router_title": "Edit Router",
|
||||||
|
"add_router_title": "Tambah Router",
|
||||||
|
"connect_desc": "Hubungkan Mikhmon ke perangkat RouterOS Anda.",
|
||||||
|
"session_settings": "Pengaturan Sesi",
|
||||||
|
"unique_id": "ID Unik. Pratinjau:",
|
||||||
|
"show_quick_access": "Tampilkan di Akses Cepat (Beranda)",
|
||||||
|
"connection_details": "Detail Koneksi",
|
||||||
|
"password_hint": "Biarkan kosong untuk menggunakan kata sandi yang ada.",
|
||||||
|
"hotspot_info": "Informasi Hotspot",
|
||||||
|
"dns_name": "Nama DNS",
|
||||||
|
"traffic_interface": "Antarmuka Trafik",
|
||||||
|
"check_btn": "Periksa",
|
||||||
|
"currency": "Mata Uang",
|
||||||
|
"auto_reload": "Auto Reload (Detik)",
|
||||||
|
"save_connect": "Simpan & Hubungkan"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Selamat datang kembali, silakan masuk untuk melanjutkan.",
|
"welcome": "Selamat datang kembali, silakan masuk untuk melanjutkan.",
|
||||||
@@ -244,9 +264,9 @@
|
|||||||
"host": "Nama Host"
|
"host": "Nama Host"
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
"title": "Cookie Hotspot",
|
"title": "Pengguna Hotspot",
|
||||||
"subtitle": "Cookie autentikasi aktif untuk:",
|
"subtitle": "Kelola pengguna dan voucher",
|
||||||
"user": "User",
|
"name": "Nama",
|
||||||
"mac": "Alamat MAC",
|
"mac": "Alamat MAC",
|
||||||
"ip": "Alamat IP",
|
"ip": "Alamat IP",
|
||||||
"expires": "Kedaluwarsa Dalam",
|
"expires": "Kedaluwarsa Dalam",
|
||||||
@@ -326,6 +346,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotspot_users": {
|
"hotspot_users": {
|
||||||
|
"add_user": "Tambah Pengguna",
|
||||||
|
"edit_user": "Edit Pengguna",
|
||||||
|
"title": "Pengguna Hotspot",
|
||||||
|
"subtitle": "Kelola pengguna dan voucher",
|
||||||
|
"name": "Nama",
|
||||||
|
"profile": "Profil",
|
||||||
|
"uptime_limit": "Waktu Aktif / Batas",
|
||||||
|
"bytes_in_out": "Bytes Masuk/Keluar",
|
||||||
|
"comment": "Komentar",
|
||||||
|
"no_users_selected": "Tidak ada pengguna yang dipilih.",
|
||||||
"form": {
|
"form": {
|
||||||
"add_title": "Tambah User",
|
"add_title": "Tambah User",
|
||||||
"edit_title": "Edit User",
|
"edit_title": "Edit User",
|
||||||
@@ -550,5 +580,38 @@
|
|||||||
"cors_rule_updated_desc": "Perubahan pada aturan CORS untuk {origin} berhasil disimpan.",
|
"cors_rule_updated_desc": "Perubahan pada aturan CORS untuk {origin} berhasil disimpan.",
|
||||||
"cors_rule_deleted": "Aturan CORS Dihapus",
|
"cors_rule_deleted": "Aturan CORS Dihapus",
|
||||||
"cors_rule_deleted_desc": "Aturan CORS berhasil dihapus."
|
"cors_rule_deleted_desc": "Aturan CORS berhasil dihapus."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"check_title": "Cek Status Voucher",
|
||||||
|
"check_desc": "Pantau penggunaan data dan masa aktif voucher Anda secara real-time tanpa perlu login ulang.",
|
||||||
|
"voucher_code_label": "Kode Voucher",
|
||||||
|
"voucher_code_placeholder": "Contoh: QWASZX",
|
||||||
|
"code_placeholder": "Contoh: QWASZX",
|
||||||
|
"check_now": "Cek Sekarang",
|
||||||
|
"details_title": "Detail Voucher",
|
||||||
|
"code": "Kode Voucher",
|
||||||
|
"data_remaining": "Sisa Kuota",
|
||||||
|
"used": "Terpakai",
|
||||||
|
"package": "Paket",
|
||||||
|
"validity": "Masa Aktif",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"expires": "Kedaluwarsa Pada",
|
||||||
|
"not_found_title": "Voucher Tidak Ditemukan",
|
||||||
|
"not_found_desc": "Kode voucher yang Anda masukkan tidak ada.",
|
||||||
|
"try_again": "Coba Lagi"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"404_title": "Halaman Tidak Ditemukan",
|
||||||
|
"404_desc": "Halaman yang Anda cari mungkin telah dihapus, namanya diganti, atau sedang tidak tersedia sementara.",
|
||||||
|
"403_title": "Akses Ditolak",
|
||||||
|
"403_desc": "Anda tidak memiliki izin untuk mengakses sumber daya ini.",
|
||||||
|
"500_title": "Kesalahan Server",
|
||||||
|
"500_desc": "Terjadi kesalahan di sisi kami. Silakan coba lagi nanti.",
|
||||||
|
"503_title": "Layanan Tidak Tersedia",
|
||||||
|
"503_desc": "Server saat ini tidak dapat menangani permintaan karena pemeliharaan atau kelebihan beban.",
|
||||||
|
"router_not_found_title": "Router Tidak Ditemukan",
|
||||||
|
"router_not_found_desc": "Sesi router yang Anda coba akses tidak ada atau telah dihapus.",
|
||||||
|
"return_home": "Kembali ke Beranda",
|
||||||
|
"go_back": "Kembali"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,17 +4,16 @@
|
|||||||
// These routes do not use the session in the URL prefix by default,
|
// These routes do not use the session in the URL prefix by default,
|
||||||
// but might require session/id in the POST body for authentication context.
|
// but might require session/id in the POST body for authentication context.
|
||||||
|
|
||||||
// API CORS Handling
|
// Apply Global CORS to all API routes
|
||||||
if (strpos($_SERVER['REQUEST_URI'] ?? '', '/api/') !== false) {
|
$router->group(['middleware' => 'cors'], function($router) {
|
||||||
\App\Core\Middleware::cors();
|
|
||||||
}
|
|
||||||
|
|
||||||
$router->post('/api/router/interfaces', [App\Controllers\ApiController::class, 'getInterfaces']);
|
$router->post('/api/router/interfaces', [App\Controllers\ApiController::class, 'getInterfaces']);
|
||||||
|
|
||||||
|
// Public Status API (No Auth Check in Controller)
|
||||||
|
$router->post('/api/status/check', [App\Controllers\PublicStatusController::class, 'check']);
|
||||||
|
|
||||||
// Public Status API (No Auth Check in Controller)
|
// Voucher Check (Code/Username in URL) - Support GET (Status Page) and POST (Login Page Check)
|
||||||
$router->post('/api/status/check', [App\Controllers\PublicStatusController::class, 'check']);
|
$router->post('/api/voucher/check/{code}', [App\Controllers\PublicStatusController::class, 'check']);
|
||||||
|
$router->get('/api/voucher/check/{code}', [App\Controllers\PublicStatusController::class, 'check']);
|
||||||
|
|
||||||
// Voucher Check (Code/Username in URL) - Support GET (Status Page) and POST (Login Page Check)
|
});
|
||||||
$router->post('/api/voucher/check/{code}', [App\Controllers\PublicStatusController::class, 'check']);
|
|
||||||
$router->get('/api/voucher/check/{code}', [App\Controllers\PublicStatusController::class, 'check']);
|
|
||||||
|
|||||||
254
routes/web.php
254
routes/web.php
@@ -1,125 +1,163 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Auth Routes
|
use App\Controllers\InstallController;
|
||||||
$router->get('/install', [App\Controllers\InstallController::class, 'index']);
|
use App\Controllers\AuthController;
|
||||||
$router->post('/install', [App\Controllers\InstallController::class, 'process']);
|
use App\Controllers\HomeController;
|
||||||
|
use App\Controllers\PublicStatusController;
|
||||||
|
use App\Controllers\SettingsController;
|
||||||
|
use App\Controllers\VoucherTemplateController;
|
||||||
|
use App\Controllers\ProfileController;
|
||||||
|
use App\Controllers\HotspotController;
|
||||||
|
use App\Controllers\GeneratorController;
|
||||||
|
use App\Controllers\DashboardController;
|
||||||
|
use App\Controllers\TrafficController;
|
||||||
|
use App\Controllers\ReportController;
|
||||||
|
use App\Controllers\LogController;
|
||||||
|
use App\Controllers\SystemController;
|
||||||
|
use App\Controllers\SchedulerController;
|
||||||
|
use App\Controllers\DhcpController;
|
||||||
|
use App\Controllers\QuickPrintController;
|
||||||
|
|
||||||
$router->get('/login', [App\Controllers\AuthController::class, 'showLogin']);
|
// -----------------------------------------------------------------------------
|
||||||
$router->post('/login', [App\Controllers\AuthController::class, 'login']);
|
// Public Routes (No Auth Required)
|
||||||
$router->get('/logout', [App\Controllers\AuthController::class, 'logout']);
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// Home
|
// Installer
|
||||||
$router->get('/', [App\Controllers\HomeController::class, 'index']);
|
$router->get('/install', [InstallController::class, 'index']);
|
||||||
|
$router->post('/install', [InstallController::class, 'process']);
|
||||||
|
|
||||||
// Design System / Components
|
// Authentication
|
||||||
$router->get('/design-system', [App\Controllers\HomeController::class, 'designSystem']);
|
$router->get('/login', [AuthController::class, 'showLogin']);
|
||||||
|
$router->post('/login', [AuthController::class, 'login']);
|
||||||
|
$router->get('/logout', [AuthController::class, 'logout']);
|
||||||
|
|
||||||
|
// Public Status Check (Requires Valid Router Session, but NO Auth)
|
||||||
|
$router->group(['middleware' => 'router.valid'], function($router) {
|
||||||
|
$router->get('/{session}/status', [PublicStatusController::class, 'index']);
|
||||||
|
});
|
||||||
|
|
||||||
// Temporary Test Route
|
// Temporary Test Route
|
||||||
$router->get('/test-alert', [App\Controllers\HomeController::class, 'testAlert']);
|
$router->get('/test-alert', [HomeController::class, 'testAlert']);
|
||||||
|
|
||||||
// Public Status Check
|
|
||||||
$router->get('/{session}/status', [App\Controllers\PublicStatusController::class, 'index']);
|
|
||||||
|
|
||||||
// Routers Settings and Systems Settings Routers
|
|
||||||
$router->get('/settings', [App\Controllers\SettingsController::class, 'routers']); // Default to Routers
|
|
||||||
$router->get('/settings/system', [App\Controllers\SettingsController::class, 'system']); // Renamed General
|
|
||||||
$router->get('/settings/routers', [App\Controllers\SettingsController::class, 'routers']);
|
|
||||||
$router->get('/settings/add', [App\Controllers\SettingsController::class, 'add']);
|
|
||||||
$router->post('/settings/store', [App\Controllers\SettingsController::class, 'store']);
|
|
||||||
$router->get('/settings/edit/{id}', [App\Controllers\SettingsController::class, 'edit']);
|
|
||||||
$router->post('/settings/update', [App\Controllers\SettingsController::class, 'update']);
|
|
||||||
$router->post('/settings/delete', [App\Controllers\SettingsController::class, 'delete']);
|
|
||||||
$router->post('/settings/admin/update', [App\Controllers\SettingsController::class, 'updateAdmin']);
|
|
||||||
$router->post('/settings/global/update', [App\Controllers\SettingsController::class, 'updateGlobal']);
|
|
||||||
$router->get('/settings/backup', [App\Controllers\SettingsController::class, 'backup']);
|
|
||||||
$router->post('/settings/restore', [App\Controllers\SettingsController::class, 'restore']);
|
|
||||||
|
|
||||||
// Settings - Templates Routes
|
|
||||||
$router->get('/settings/templates', [App\Controllers\TemplateController::class, 'index']);
|
|
||||||
$router->get('/settings/templates/preview/{id}', [App\Controllers\TemplateController::class, 'preview']);
|
|
||||||
$router->get('/settings/templates/add', [App\Controllers\TemplateController::class, 'add']);
|
|
||||||
$router->post('/settings/templates/store', [App\Controllers\TemplateController::class, 'store']);
|
|
||||||
$router->get('/settings/templates/edit/{id}', [App\Controllers\TemplateController::class, 'edit']);
|
|
||||||
$router->post('/settings/templates/update', [App\Controllers\TemplateController::class, 'update']);
|
|
||||||
$router->post('/settings/templates/delete', [App\Controllers\TemplateController::class, 'delete']);
|
|
||||||
|
|
||||||
// Logo Management Routes
|
|
||||||
$router->get('/settings/logos', [App\Controllers\SettingsController::class, 'logos']);
|
|
||||||
$router->post('/settings/logos/upload', [App\Controllers\SettingsController::class, 'uploadLogo']);
|
|
||||||
$router->post('/settings/logos/delete', [App\Controllers\SettingsController::class, 'deleteLogo']);
|
|
||||||
|
|
||||||
// API CORS Routes
|
|
||||||
$router->get('/settings/api-cors', [App\Controllers\SettingsController::class, 'apiCors']);
|
|
||||||
$router->post('/settings/api-cors/store', [App\Controllers\SettingsController::class, 'storeApiCors']);
|
|
||||||
$router->post('/settings/api-cors/update', [App\Controllers\SettingsController::class, 'updateApiCors']);
|
|
||||||
$router->post('/settings/api-cors/delete', [App\Controllers\SettingsController::class, 'deleteApiCors']);
|
|
||||||
|
|
||||||
|
|
||||||
// Hotspot - Profiles
|
// -----------------------------------------------------------------------------
|
||||||
$router->get('/{session}/hotspot/profiles', [App\Controllers\ProfileController::class, 'index']);
|
// Protected Admin Routes (Requires Auth)
|
||||||
$router->get('/{session}/hotspot/profile/add', [App\Controllers\ProfileController::class, 'add']);
|
// -----------------------------------------------------------------------------
|
||||||
$router->post('/{session}/hotspot/profile/store', [App\Controllers\ProfileController::class, 'store']);
|
|
||||||
$router->post('/{session}/hotspot/profile/delete', [App\Controllers\ProfileController::class, 'delete']);
|
|
||||||
$router->get('/{session}/hotspot/profile/edit/{id}', [App\Controllers\ProfileController::class, 'edit']);
|
|
||||||
$router->post('/{session}/hotspot/profile/update', [App\Controllers\ProfileController::class, 'update']);
|
|
||||||
|
|
||||||
// Hotspot - Users
|
$router->group(['middleware' => 'auth'], function($router) {
|
||||||
$router->get('/{session}/hotspot/users', [App\Controllers\HotspotController::class, 'index']);
|
|
||||||
$router->get('/{session}/hotspot/add', [App\Controllers\HotspotController::class, 'add']);
|
|
||||||
$router->post('/{session}/hotspot/store', [App\Controllers\HotspotController::class, 'store']);
|
|
||||||
$router->post('/{session}/hotspot/delete', [App\Controllers\HotspotController::class, 'delete']);
|
|
||||||
$router->get('/{session}/hotspot/user/edit/{id}', [App\Controllers\HotspotController::class, 'edit']);
|
|
||||||
$router->post('/{session}/hotspot/update', [App\Controllers\HotspotController::class, 'update']);
|
|
||||||
$router->get('/{session}/hotspot/print-batch', [App\Controllers\HotspotController::class, 'printBatchActions']);
|
|
||||||
$router->get('/{session}/hotspot/print/([a-zA-Z0-9*]+)', [App\Controllers\HotspotController::class, 'printUser']); // Handle Microtik IDs often having *
|
|
||||||
|
|
||||||
// Hotspot - Active & Hosts (New)
|
// Global Home / Design System
|
||||||
$router->get('/{session}/hotspot/active', [App\Controllers\HotspotController::class, 'active']);
|
$router->get('/', [HomeController::class, 'index']);
|
||||||
$router->post('/{session}/hotspot/active/remove', [App\Controllers\HotspotController::class, 'removeActive']);
|
$router->get('/design-system', [HomeController::class, 'designSystem']);
|
||||||
$router->get('/{session}/hotspot/hosts', [App\Controllers\HotspotController::class, 'hosts']);
|
|
||||||
$router->get('/{session}/hotspot/bindings', [App\Controllers\HotspotController::class, 'bindings']);
|
|
||||||
$router->post('/{session}/hotspot/bindings/store', [App\Controllers\HotspotController::class, 'storeBinding']);
|
|
||||||
$router->post('/{session}/hotspot/bindings/remove', [App\Controllers\HotspotController::class, 'removeBinding']);
|
|
||||||
$router->get('/{session}/hotspot/walled-garden', [App\Controllers\HotspotController::class, 'walledGarden']);
|
|
||||||
$router->post('/{session}/hotspot/walled-garden/store', [App\Controllers\HotspotController::class, 'storeWalledGarden']);
|
|
||||||
$router->post('/{session}/hotspot/walled-garden/remove', [App\Controllers\HotspotController::class, 'removeWalledGarden']);
|
|
||||||
|
|
||||||
// Hotspot - Generate
|
// Global Settings (Admin Level)
|
||||||
$router->get('/{session}/hotspot/generate', [App\Controllers\GeneratorController::class, 'index']);
|
$router->get('/settings', [SettingsController::class, 'routers']);
|
||||||
$router->post('/{session}/hotspot/generate/process', [App\Controllers\GeneratorController::class, 'process']);
|
$router->get('/settings/system', [SettingsController::class, 'system']);
|
||||||
|
$router->get('/settings/routers', [SettingsController::class, 'routers']);
|
||||||
|
$router->get('/settings/add', [SettingsController::class, 'add']);
|
||||||
|
$router->post('/settings/store', [SettingsController::class, 'store']);
|
||||||
|
$router->get('/settings/edit/{id}', [SettingsController::class, 'edit']);
|
||||||
|
$router->post('/settings/update', [SettingsController::class, 'update']);
|
||||||
|
$router->post('/settings/delete', [SettingsController::class, 'delete']);
|
||||||
|
$router->post('/settings/admin/update', [SettingsController::class, 'updateAdmin']);
|
||||||
|
$router->post('/settings/global/update', [SettingsController::class, 'updateGlobal']);
|
||||||
|
$router->get('/settings/backup', [SettingsController::class, 'backup']);
|
||||||
|
$router->post('/settings/restore', [SettingsController::class, 'restore']);
|
||||||
|
|
||||||
// Dashboard
|
// Voucher Templates
|
||||||
$router->get('/{session}/dashboard', [App\Controllers\DashboardController::class, 'index']);
|
$router->get('/settings/voucher-templates', [VoucherTemplateController::class, 'index']);
|
||||||
|
$router->get('/settings/voucher-templates/preview/{id}', [VoucherTemplateController::class, 'preview']);
|
||||||
|
$router->get('/settings/voucher-templates/add', [VoucherTemplateController::class, 'add']);
|
||||||
|
$router->post('/settings/voucher-templates/store', [VoucherTemplateController::class, 'store']);
|
||||||
|
$router->get('/settings/voucher-templates/edit/{id}', [VoucherTemplateController::class, 'edit']);
|
||||||
|
$router->post('/settings/voucher-templates/update', [VoucherTemplateController::class, 'update']);
|
||||||
|
$router->post('/settings/voucher-templates/delete', [VoucherTemplateController::class, 'delete']);
|
||||||
|
|
||||||
// Traffic Monitor (API)
|
// Logo Management
|
||||||
$router->get('/{session}/traffic/monitor', [App\Controllers\TrafficController::class, 'monitor']);
|
$router->get('/settings/logos', [SettingsController::class, 'logos']);
|
||||||
$router->get('/{session}/traffic/interfaces', [App\Controllers\TrafficController::class, 'getInterfaces']);
|
$router->post('/settings/logos/upload', [SettingsController::class, 'uploadLogo']);
|
||||||
|
$router->post('/settings/logos/delete', [SettingsController::class, 'deleteLogo']);
|
||||||
|
|
||||||
// Reports
|
// API CORS Settings
|
||||||
$router->get('/{session}/reports/selling', [App\Controllers\ReportController::class, 'index']);
|
$router->get('/settings/api-cors', [SettingsController::class, 'apiCors']);
|
||||||
$router->get('/{session}/reports/resume', [App\Controllers\ReportController::class, 'resume']);
|
$router->post('/settings/api-cors/store', [SettingsController::class, 'storeApiCors']);
|
||||||
$router->get('/{session}/reports/user-log', [App\Controllers\LogController::class, 'index']);
|
$router->post('/settings/api-cors/update', [SettingsController::class, 'updateApiCors']);
|
||||||
|
$router->post('/settings/api-cors/delete', [SettingsController::class, 'deleteApiCors']);
|
||||||
// System Tools
|
|
||||||
$router->post('/{session}/system/reboot', [App\Controllers\SystemController::class, 'reboot']);
|
|
||||||
$router->post('/{session}/system/shutdown', [App\Controllers\SystemController::class, 'shutdown']);
|
|
||||||
$router->get('/{session}/system/scheduler', [App\Controllers\SchedulerController::class, 'index']);
|
|
||||||
$router->post('/{session}/system/scheduler/store', [App\Controllers\SchedulerController::class, 'store']);
|
|
||||||
$router->post('/{session}/system/scheduler/update', [App\Controllers\SchedulerController::class, 'update']);
|
|
||||||
$router->post('/{session}/system/scheduler/delete', [App\Controllers\SchedulerController::class, 'delete']);
|
|
||||||
|
|
||||||
// Network & Cookies
|
|
||||||
$router->get('/{session}/network/dhcp', [App\Controllers\DhcpController::class, 'index']);
|
|
||||||
$router->get('/{session}/hotspot/cookies', [App\Controllers\HotspotController::class, 'cookies']);
|
|
||||||
$router->post('/{session}/hotspot/cookies/remove', [App\Controllers\HotspotController::class, 'removeCookie']);
|
|
||||||
|
|
||||||
// Quick Print Routes
|
|
||||||
$router->get('/{session}/quick-print', [App\Controllers\QuickPrintController::class, 'index']);
|
|
||||||
$router->get('/{session}/quick-print/manage', [App\Controllers\QuickPrintController::class, 'manage']);
|
|
||||||
$router->post('/{session}/quick-print/store', [App\Controllers\QuickPrintController::class, 'store']);
|
|
||||||
$router->post('/{session}/quick-print/delete', [App\Controllers\QuickPrintController::class, 'delete']);
|
|
||||||
$router->get('/{session}/quick-print/print/([a-zA-Z0-9_-]+)', [App\Controllers\QuickPrintController::class, 'printPacket']);
|
|
||||||
|
|
||||||
|
|
||||||
// API Routes
|
// -------------------------------------------------------------------------
|
||||||
// API Routes (Moved to routes/api.php)
|
// Router Context Routes (Requires Auth AND Valid Router Session)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// These routes rely on {session} parameter and middleware checks if it exists.
|
||||||
|
|
||||||
|
$router->group(['middleware' => 'router.valid'], function($router) {
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
$router->get('/{session}/dashboard', [DashboardController::class, 'index']);
|
||||||
|
|
||||||
|
// Hotspot - Profiles
|
||||||
|
$router->get('/{session}/hotspot/profiles', [ProfileController::class, 'index']);
|
||||||
|
$router->get('/{session}/hotspot/profile/add', [ProfileController::class, 'add']);
|
||||||
|
$router->post('/{session}/hotspot/profile/store', [ProfileController::class, 'store']);
|
||||||
|
$router->post('/{session}/hotspot/profile/delete', [ProfileController::class, 'delete']);
|
||||||
|
$router->get('/{session}/hotspot/profile/edit/{id}', [ProfileController::class, 'edit']);
|
||||||
|
$router->post('/{session}/hotspot/profile/update', [ProfileController::class, 'update']);
|
||||||
|
|
||||||
|
// Hotspot - Users
|
||||||
|
$router->get('/{session}/hotspot/users', [HotspotController::class, 'index']);
|
||||||
|
$router->get('/{session}/hotspot/add', [HotspotController::class, 'add']);
|
||||||
|
$router->post('/{session}/hotspot/store', [HotspotController::class, 'store']);
|
||||||
|
$router->post('/{session}/hotspot/delete', [HotspotController::class, 'delete']);
|
||||||
|
$router->get('/{session}/hotspot/user/edit/{id}', [HotspotController::class, 'edit']);
|
||||||
|
$router->post('/{session}/hotspot/update', [HotspotController::class, 'update']);
|
||||||
|
$router->get('/{session}/hotspot/print-batch', [HotspotController::class, 'printBatchActions']);
|
||||||
|
$router->get('/{session}/hotspot/print/([a-zA-Z0-9*]+)', [HotspotController::class, 'printUser']);
|
||||||
|
|
||||||
|
// Hotspot - Active & Hosts
|
||||||
|
$router->get('/{session}/hotspot/active', [HotspotController::class, 'active']);
|
||||||
|
$router->post('/{session}/hotspot/active/remove', [HotspotController::class, 'removeActive']);
|
||||||
|
$router->get('/{session}/hotspot/hosts', [HotspotController::class, 'hosts']);
|
||||||
|
$router->get('/{session}/hotspot/bindings', [HotspotController::class, 'bindings']);
|
||||||
|
$router->post('/{session}/hotspot/bindings/store', [HotspotController::class, 'storeBinding']);
|
||||||
|
$router->post('/{session}/hotspot/bindings/remove', [HotspotController::class, 'removeBinding']);
|
||||||
|
$router->get('/{session}/hotspot/walled-garden', [HotspotController::class, 'walledGarden']);
|
||||||
|
$router->post('/{session}/hotspot/walled-garden/store', [HotspotController::class, 'storeWalledGarden']);
|
||||||
|
$router->post('/{session}/hotspot/walled-garden/remove', [HotspotController::class, 'removeWalledGarden']);
|
||||||
|
|
||||||
|
// Hotspot - Generate
|
||||||
|
$router->get('/{session}/hotspot/generate', [GeneratorController::class, 'index']);
|
||||||
|
$router->post('/{session}/hotspot/generate/process', [GeneratorController::class, 'process']);
|
||||||
|
|
||||||
|
// Traffic Monitor
|
||||||
|
$router->get('/{session}/traffic/monitor', [TrafficController::class, 'monitor']);
|
||||||
|
$router->get('/{session}/traffic/interfaces', [TrafficController::class, 'getInterfaces']);
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
$router->get('/{session}/reports/selling', [ReportController::class, 'index']);
|
||||||
|
$router->get('/{session}/reports/resume', [ReportController::class, 'resume']);
|
||||||
|
$router->get('/{session}/reports/user-log', [LogController::class, 'index']);
|
||||||
|
|
||||||
|
// System Tools
|
||||||
|
$router->post('/{session}/system/reboot', [SystemController::class, 'reboot']);
|
||||||
|
$router->post('/{session}/system/shutdown', [SystemController::class, 'shutdown']);
|
||||||
|
$router->get('/{session}/system/scheduler', [SchedulerController::class, 'index']);
|
||||||
|
$router->post('/{session}/system/scheduler/store', [SchedulerController::class, 'store']);
|
||||||
|
$router->post('/{session}/system/scheduler/update', [SchedulerController::class, 'update']);
|
||||||
|
$router->post('/{session}/system/scheduler/delete', [SchedulerController::class, 'delete']);
|
||||||
|
|
||||||
|
// Network & Cookies
|
||||||
|
$router->get('/{session}/network/dhcp', [DhcpController::class, 'index']);
|
||||||
|
$router->get('/{session}/hotspot/cookies', [HotspotController::class, 'cookies']);
|
||||||
|
$router->post('/{session}/hotspot/cookies/remove', [HotspotController::class, 'removeCookie']);
|
||||||
|
|
||||||
|
// Quick Print
|
||||||
|
$router->get('/{session}/quick-print', [QuickPrintController::class, 'index']);
|
||||||
|
$router->get('/{session}/quick-print/manage', [QuickPrintController::class, 'manage']);
|
||||||
|
$router->post('/{session}/quick-print/store', [QuickPrintController::class, 'store']);
|
||||||
|
$router->post('/{session}/quick-print/update', [QuickPrintController::class, 'update']);
|
||||||
|
$router->post('/{session}/quick-print/delete', [QuickPrintController::class, 'delete']);
|
||||||
|
$router->get('/{session}/quick-print/print/([a-zA-Z0-9_-]+)', [QuickPrintController::class, 'printPacket']);
|
||||||
|
|
||||||
|
}); // End Router Context Group
|
||||||
|
}); // End Auth Group
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
@apply p-2 rounded-lg text-red-500 transition-all duration-200 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 active:scale-95;
|
@apply p-2 rounded-lg text-red-500 transition-all duration-200 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 active:scale-95;
|
||||||
}
|
}
|
||||||
.form-label {
|
.form-label {
|
||||||
@apply block text-sm font-medium text-accents-5 mb-1 transition-colors duration-200;
|
@apply block text-xs font-bold text-accents-6 dark:text-accents-3 uppercase tracking-wider mb-1 transition-colors duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label-card {
|
.form-label-card {
|
||||||
@@ -133,18 +133,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
@apply appearance-none h-5 w-5 shrink-0 rounded border border-accents-5 bg-white/50 dark:bg-white/5 backdrop-blur-sm shadow-sm transition-all duration-200 checked:bg-foreground checked:border-foreground focus:outline-none focus:ring-2 focus:ring-accents-2 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer hover:border-foreground hover:shadow-md;
|
@apply appearance-none h-5 w-5 shrink-0 rounded border border-accents-5 dark:border-white/30 bg-white/50 dark:bg-white/10 backdrop-blur-sm shadow-sm transition-all duration-200 checked:bg-blue-600 checked:border-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-600/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-md;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: 100%;
|
background-size: 100%;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox:checked {
|
.checkbox:checked {
|
||||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .checkbox:checked {
|
.dark .checkbox:checked {
|
||||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='black' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
.card, .glass-card {
|
.card, .glass-card {
|
||||||
@@ -160,13 +160,21 @@
|
|||||||
|
|
||||||
/* Custom Select Dropdown Global Style */
|
/* Custom Select Dropdown Global Style */
|
||||||
.custom-select-dropdown {
|
.custom-select-dropdown {
|
||||||
@apply absolute z-50 min-w-full mt-1 bg-white/80 dark:bg-black/80 backdrop-blur-[40px] border border-white/20 dark:border-white/10 rounded-xl shadow-2xl transition-all duration-200 ease-out origin-top opacity-0 scale-95 -translate-y-2 invisible pointer-events-none flex flex-col max-h-60 overflow-hidden ring-1 ring-black/5;
|
@apply absolute z-50 min-w-full top-full mt-1 bg-white/80 dark:bg-black/80 backdrop-blur-[40px] border border-white/20 dark:border-white/10 rounded-xl shadow-2xl transition-all duration-200 ease-out origin-top opacity-0 scale-95 -translate-y-2 invisible pointer-events-none flex flex-col max-h-60 overflow-hidden ring-1 ring-black/5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-dropdown.open {
|
.custom-select-dropdown.open {
|
||||||
@apply opacity-100 scale-100 translate-y-0 visible pointer-events-auto;
|
@apply opacity-100 scale-100 translate-y-0 visible pointer-events-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-select-dropdown.dropdown-up {
|
||||||
|
@apply bottom-full top-auto mb-1 mt-0 origin-bottom translate-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-dropdown.dropdown-up.open {
|
||||||
|
@apply translate-y-0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Premium Control Pill & Segmented Switch */
|
/* Premium Control Pill & Segmented Switch */
|
||||||
.control-pill {
|
.control-pill {
|
||||||
@apply flex items-center gap-1 px-1.5 h-10 rounded-full border-2 border-accents-2 bg-white/60 dark:bg-black/40 backdrop-blur-md shadow-sm transition-all duration-300 hover:shadow-md hover:border-accents-3 hover:bg-white/80 dark:hover:bg-black/60;
|
@apply flex items-center gap-1 px-1.5 h-10 rounded-full border-2 border-accents-2 bg-white/60 dark:bg-black/40 backdrop-blur-md shadow-sm transition-all duration-300 hover:shadow-md hover:border-accents-3 hover:bg-white/80 dark:hover:bg-black/60;
|
||||||
@@ -219,6 +227,24 @@
|
|||||||
.pill-lang-btn {
|
.pill-lang-btn {
|
||||||
@apply flex items-center justify-center h-8 w-8 rounded-full text-foreground hover:bg-accents-2 transition-all duration-200 outline-none focus:ring-1 focus:ring-accents-3;
|
@apply flex items-center justify-center h-8 w-8 rounded-full text-foreground hover:bg-accents-2 transition-all duration-200 outline-none focus:ring-1 focus:ring-accents-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dropdown Bridge to prevent accidental closure on margin gaps */
|
||||||
|
.dropdown-bridge::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -1.25rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific Bridge expansion for Notification to make it more "sticky" */
|
||||||
|
#notification-dropdown.dropdown-bridge::before {
|
||||||
|
inset: -2rem -3rem; /* Expand 32px top/bottom, 48px left/right */
|
||||||
|
top: -2.5rem; /* Ensure it covers the gap to the button */
|
||||||
|
}
|
||||||
/* Glassmorphism Table */
|
/* Glassmorphism Table */
|
||||||
.table-container {
|
.table-container {
|
||||||
@apply w-full overflow-x-auto rounded-xl border-2 border-black/5 dark:border-white/10 shadow-sm;
|
@apply w-full overflow-x-auto rounded-xl border-2 border-black/5 dark:border-white/10 shadow-sm;
|
||||||
@@ -345,6 +371,15 @@ div.swal2-popup {
|
|||||||
padding: 1.5rem !important;
|
padding: 1.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div:where(.swal2-container) {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:where(.swal2-popup).swal-wide {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 900px !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode background fix for glassmorphism */
|
/* Dark mode background fix for glassmorphism */
|
||||||
.dark div.swal2-popup {
|
.dark div.swal2-popup {
|
||||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||||
@@ -538,3 +573,55 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
background: rgba(0, 0, 0, 0.4) !important;
|
background: rgba(0, 0, 0, 0.4) !important;
|
||||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
}
|
}
|
||||||
|
/* SweetAlert2 Premium Input Styles Override */
|
||||||
|
.swal2-premium-card .form-label {
|
||||||
|
@apply text-accents-8 dark:text-accents-2 font-bold mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swal2-premium-card input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
.swal2-premium-card select,
|
||||||
|
.swal2-premium-card textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: var(--accents-2);
|
||||||
|
background-color: rgb(255 255 255 / 0.5);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
transition-property: all;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swal2-premium-card input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||||
|
.swal2-premium-card select:focus,
|
||||||
|
.swal2-premium-card textarea:focus {
|
||||||
|
border-color: var(--foreground);
|
||||||
|
background-color: rgb(255 255 255 / 0.8);
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
--tw-ring-color: var(--accents-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .swal2-premium-card input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
.dark .swal2-premium-card select,
|
||||||
|
.dark .swal2-premium-card textarea {
|
||||||
|
background-color: rgb(0 0 0 / 0.2);
|
||||||
|
border-color: var(--accents-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .swal2-premium-card input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||||
|
.dark .swal2-premium-card select:focus,
|
||||||
|
.dark .swal2-premium-card textarea:focus {
|
||||||
|
background-color: rgb(0 0 0 / 0.4);
|
||||||
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user