12 Commits

Author SHA1 Message Date
dyzulk
b245f31236 feat: Implement an installation process including a setup UI, database migrations, and admin account creation. 2026-01-17 13:57:08 +07:00
dyzulk
e8ffea2c58 fix: Set HOME environment variable at script start and add explicit permissions for node_modules and the tailwindcss binary. 2026-01-17 13:49:58 +07:00
dyzulk
a4d0233386 fix: Resolve Git ownership, Composer, and npm environment issues in the deployment script and add cleanup for temporary files. 2026-01-17 13:47:09 +07:00
dyzulk
95ca189679 feat: Add aaPanel deployment script and exclude it from git archives. 2026-01-17 13:42:16 +07:00
dyzulk
5b0b6de2dc Chore: Bump version to v1.1.0 and implement automated release system 2026-01-17 13:01:05 +07:00
dyzulk
64609a5821 feat: implement Logo model for managing logo uploads, storage, and database persistence. 2026-01-17 04:05:51 +07:00
dyzulk
08960b540f refactor(docker): update aapanel port to 8085 to avoid conflicts 2026-01-17 03:39:44 +07:00
dyzulk
d8c1a779b8 feat: Implement aaPanel Docker deployment support with new guides and configuration files, including a minor port adjustment in the general Docker README. 2026-01-17 03:37:11 +07:00
dyzulk
4968246911 feat: Add Docker Compose template and English/Indonesian aaPanel deployment guides for the Mivo application. 2026-01-17 03:08:14 +07:00
dyzulk
ae65ab30fa feat: add aaPanel Docker deployment support and documentation 2026-01-17 03:03:23 +07:00
dyzulk
6eb6bbb359 feat: add initial MIVO documentation, including English and Indonesian guides and main pages, and update project descriptions. 2026-01-16 16:05:52 +07:00
dyzulk
7a0c6cb5c3 feat: Implement initial VitePress documentation site with multi-language navigation and Open Graph metadata. 2026-01-16 15:55:12 +07:00
87 changed files with 3613 additions and 2387 deletions

View File

@@ -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

12
.gitattributes vendored
View File

@@ -2,11 +2,21 @@
/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
/aapanel_deploy.sh export-ignore

53
.github/workflows/release.yml vendored Normal file
View 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
View File

@@ -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

View File

@@ -6,7 +6,7 @@
> **Modern. Lightweight. Efficient.** > **Modern. Lightweight. Efficient.**
MIVO is a complete rewrite of the legendary **Mikhmon v3**, re-engineered with a modern MVC architecture to run efficiently on low-end devices like STB (Set Top Boxes) and Android, while providing a premium user experience on desktop. MIVO is a next-generation **Mikrotik Voucher Management System** with a modern MVC architecture, designed to run efficiently on low-end devices like STB (Set Top Boxes) and Android, while providing a premium user experience on desktop.
This Docker image is built on **Alpine Linux** and **Nginx**, optimized for high performance and low resource usage. This Docker image is built on **Alpine Linux** and **Nginx**, optimized for high performance and low resource usage.
@@ -57,6 +57,37 @@ services:
- `edge`: Bleeding edge build from the `main` branch. - `edge`: Bleeding edge build from the `main` branch.
- `v1.x.x`: Specific released versions. - `v1.x.x`: Specific released versions.
## Deploy on aaPanel (Advanced / Native Style)
This method follows the standard aaPanel "Quick Install" pattern, using full control over paths and resources via a `.env` file.
1. **Prepare Files**:
* Copy the content of [docker/aapanel-template.yml](docker/aapanel-template.yml).
* Copy the content of [docker/aapanel-env.example](docker/aapanel-env.example).
2. **Add Project in aaPanel**:
* Go to **Docker** -> **Project** -> **Add Project**.
* **Name**: `mivo`
* **Compose Template**: Paste the content of `aapanel-template.yml`.
3. **Define Configuration (.env)**:
* In the sidebar or tab for **.env** (which appears after you paste the template in some versions, or you create manually):
* Paste the content of `aapanel-env.example`.
* **Crucial Step**: Edit `APP_PATH` to match your project path (usually `/www/dk_project/mivo`).
* Adjust `APP_PORT` if needed.
4. **Confirm**: Click "Add" or "Confirm" to deploy.
5. **Setup Reverse Proxy**:
* Go to **Website** -> **Add Site** -> **Reverse Proxy**.
* Target: `http://127.0.0.1:8085` (Usage of variable `${APP_PORT}` matches this).
* Go to **Website** -> **Add Site**.
* Enter your domain name (e.g., `mivo.yourdomain.com`).
* Select **Reverse Proxy** as the PHP version (or set it up manually afterwards).
* After the site is created, click on it -> **Reverse Proxy** -> **Add Reverse Proxy**.
* **Target URL**: `http://127.0.0.1:8080` (or the port you configured).
* Save and enable SSL.
## Environment Variables ## Environment Variables
| Variable | Description | Default | | Variable | Description | Default |

View File

@@ -6,7 +6,7 @@
> **Modern. Lightweight. Efficient.** > **Modern. Lightweight. Efficient.**
MIVO is a complete rewrite of the legendary **Mikhmon v3**, re-engineered with a modern MVC architecture to run efficiently on low-end devices like STB (Set Top Boxes) and Android, while providing a premium user experience on desktop. MIVO is a next-generation **Mikrotik Voucher Management System** with a modern MVC architecture, designed to run efficiently on low-end devices like STB (Set Top Boxes) and Android, while providing a premium user experience on desktop.
![Status](https://img.shields.io/badge/Status-Beta-orange) ![PHP](https://img.shields.io/badge/PHP-8.0+-777BB4) ![License](https://img.shields.io/badge/License-MIT-green) ![Status](https://img.shields.io/badge/Status-Beta-orange) ![PHP](https://img.shields.io/badge/PHP-8.0+-777BB4) ![License](https://img.shields.io/badge/License-MIT-green)

82
aapanel_deploy.sh Normal file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
# aaPanel Webhook Deployment Script for Mivo
# Path: /www/wwwroot/<your_project_path>
PROJECT_PATH="/www/wwwroot/<your_project_path>"
# Set HOME at the beginning - essential for git and npm
export HOME="$PROJECT_PATH"
echo "---------------------------------------"
echo "Starting Deployment: $(date)"
echo "---------------------------------------"
if [ ! -d "$PROJECT_PATH" ]; then
echo "Error: Project directory $PROJECT_PATH not found."
exit 1
fi
cd $PROJECT_PATH || exit
# 1. Pull latest changes
echo "Step 1: Pulling latest changes from Git..."
# Fix for dubious ownership error - now works because HOME is set
git config --global --add safe.directory $PROJECT_PATH
git pull origin main
# 2. Install PHP dependencies
if [ -f "composer.json" ]; then
echo "Step 2: Installing PHP dependencies..."
# Set COMPOSER_HOME to avoid environment variable errors
export COMPOSER_HOME="$PROJECT_PATH/.composer"
composer install --no-interaction --optimize-autoloader --no-dev
fi
# 3. Build Assets
if [ -f "package.json" ]; then
echo "Step 3: Building assets..."
# If node_modules doesn't exist, install first
if [ ! -d "node_modules" ]; then
echo "node_modules not found, installing..."
npm install
fi
# Force permissions on the tailwind binary and its target
echo "Ensuring node_modules permissions..."
chmod -R 755 node_modules
find node_modules/.bin/ -type l -exec chmod -h 755 {} +
find node_modules/tailwindcss/ -type f -name "tailwindcss" -exec chmod +x {} +
# Try running build
npm run build
fi
# 4. Set Permissions
echo "Step 4: Setting permissions..."
chown -R www:www .
chmod -R 755 .
chmod +x mivo
chmod -R 755 public
# Ensure Database directory is writable
if [ ! -d "app/Database" ]; then
mkdir -p app/Database
chown www:www app/Database
fi
chmod 775 app/Database
if [ -f "app/Database/database.sqlite" ]; then
chmod 664 app/Database/database.sqlite
fi
# If there's a storage directory (MVC style usually has one)
if [ -d "storage" ]; then
chmod -R 775 storage
fi
# Cleanup composer home if created
if [ -d ".composer" ]; then
rm -rf .composer
fi
echo "---------------------------------------"
echo "Deployment Finished Successfully!"
echo "---------------------------------------"

View File

@@ -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';

View File

@@ -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'];
} }
} }

View File

@@ -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;
} }
} }
} }

View File

@@ -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

View File

@@ -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;
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -16,7 +16,11 @@ class InstallController extends Controller {
exit; exit;
} }
return $this->view('install'); $permissions = $this->checkPermissions();
return $this->view('install', [
'permissions' => $permissions
]);
} }
public function process() { public function process() {
@@ -25,6 +29,13 @@ class InstallController extends Controller {
exit; exit;
} }
$permissions = $this->checkPermissions();
if (!$permissions['db_writable'] || !$permissions['root_writable']) {
\App\Helpers\FlashHelper::set('error', 'Izin Ditolak', 'Pastikan folder app/Database dan root direktori dapat ditulis oleh server web.');
header('Location: /install');
exit;
}
$username = $_POST['username'] ?? 'admin'; $username = $_POST['username'] ?? 'admin';
$password = $_POST['password'] ?? 'admin'; $password = $_POST['password'] ?? 'admin';
@@ -63,6 +74,17 @@ class InstallController extends Controller {
} }
} }
private function checkPermissions() {
$dbDir = ROOT . '/app/Database';
$envFile = ROOT . '/.env';
return [
'db_writable' => is_writable($dbDir),
'env_writable' => file_exists($envFile) ? is_writable($envFile) : is_writable(ROOT),
'root_writable' => is_writable(ROOT)
];
}
private function isInstalled() { private function isInstalled() {
// Check if .env exists and APP_KEY is set to something other than the default/example // Check if .env exists and APP_KEY is set to something other than the default/example
$envPath = ROOT . '/.env'; $envPath = ROOT . '/.env';

View File

@@ -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', [

View File

@@ -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'
]);
} }
} }

View File

@@ -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);

View File

@@ -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;
} }

View File

@@ -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');
} }

View File

@@ -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;
} }
} }

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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]();

0
app/Database/.gitkeep Normal file
View File

View File

@@ -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;
} }
} }

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Middleware;
interface MiddlewareInterface {
public function handle($request, \Closure $next);
}

View 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);
}
}

View File

@@ -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'],

View File

@@ -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,17 +98,21 @@ 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 = glob($logoDir . '*.{jpg,jpeg,png,gif,svg}', GLOB_BRACE); $files = [];
$extensions = ['jpg', 'jpeg', 'png', 'gif', 'svg'];
foreach ($extensions as $ext) {
$files = array_merge($files, glob($logoDir . '*.' . $ext));
}
foreach ($files as $file) { foreach ($files as $file) {
$filename = basename($file); $filename = basename($file);
$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) {

View File

@@ -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'] ?? '',

View File

@@ -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']

View File

@@ -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();
}); });
}); });

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'; ?>

View File

@@ -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');
}
} }
selectAll.addEventListener('change', (e) => { if(selectAll) {
const isChecked = e.target.checked; selectAll.addEventListener('change', (e) => {
// Only select visible rows on current page const isChecked = e.target.checked;
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox'); // Only select visible rows
visibleCheckboxes.forEach(cb => cb.checked = isChecked); const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
updateToolbar(); visibleCheckboxes.forEach(cb => cb.checked = isChecked);
});
// Event Delegation for dynamic rows
tableBody.addEventListener('change', (e) => {
if (e.target.classList.contains('user-checkbox')) {
updateToolbar(); updateToolbar();
if (!e.target.checked) selectAll.checked = false; });
} }
});
if(tableBody) {
tableBody.addEventListener('change', (e) => {
if (e.target.classList.contains('user-checkbox')) {
updateToolbar();
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>

View File

@@ -20,6 +20,26 @@
</div> </div>
<div class="card p-6 sm:p-8 space-y-6"> <div class="card p-6 sm:p-8 space-y-6">
<?php if (isset($permissions) && (!$permissions['db_writable'] || !$permissions['root_writable'])): ?>
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<div class="flex items-center gap-3 text-red-500 mb-2">
<i class="ph-bold ph-warning text-lg"></i>
<h4 class="font-bold text-sm">Peringatan Izin Direktori</h4>
</div>
<ul class="text-xs text-red-400 space-y-1 list-disc list-inside">
<?php if (!$permissions['db_writable']): ?>
<li>Folder <code>app/Database</code> harus writable (chmod 775/777).</li>
<?php endif; ?>
<?php if (!$permissions['root_writable']): ?>
<li>Root direktori harus writable untuk membuat file <code>.env</code>.</li>
<?php endif; ?>
</ul>
<p class="text-[10px] text-red-400/70 mt-3 pt-3 border-t border-red-500/10">
Silakan perbaiki izin folder di server Anda sebelum melanjutkan.
</p>
</div>
<?php endif; ?>
<form action="/install" method="POST" class="space-y-6"> <form action="/install" method="POST" class="space-y-6">
<!-- Steps UI --> <!-- Steps UI -->

View File

@@ -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>

View File

@@ -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">
<?= \App\Config\SiteConfig::getFooter() ?> <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>
</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}`);
} }
} }
}; };

View File

@@ -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 */

View File

@@ -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> <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>
<!-- 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.add('dark');
htmlElement.classList.remove('dark'); localStorage.theme = 'dark';
localStorage.theme = 'light'; glider.style.transform = 'translateX(100%)';
} else { // adjustment: logic depends on width.
htmlElement.classList.add('dark'); // container is w-8+w-8+padding.
localStorage.theme = 'dark'; // 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>

View File

@@ -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">

View File

@@ -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();

View File

@@ -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'],
]; ];

View File

@@ -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: { didClose: () => {
popup: 'swal2-premium-card', setTimeout(() => {
confirmButton: 'btn btn-primary', const el = document.getElementById('voucher-code');
}, if(el) { el.focus(); el.select(); }
buttonsStyling: false, }, 100);
didClose: () => { }
setTimeout(() => {
const el = document.getElementById('voucher-code');
if(el) { el.focus(); el.select(); }
}, 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',

View File

@@ -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,96 +111,84 @@ 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"> <input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<h3 class="text-lg font-bold text-foreground" id="modal-title" data-i18n="quick_print.add_package">Add Package</h3> <!-- Hidden ID for Edit Mode (will be disabled/removed for Add) -->
<button onclick="closeModal()" class="text-accents-5 hover:text-foreground"> <input type="hidden" name="id" id="form-id" disabled>
<i data-lucide="x" class="w-5 h-5"></i>
</button> <!-- Quick Inputs Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="col-span-1 md:col-span-2">
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label>
<input type="text" name="name" required class="w-full" placeholder="e.g. 3 Hours Voucher">
</div>
<div>
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
<select name="profile" class="w-full" data-search="true">
<?php foreach($profiles as $p): ?>
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
<select name="color" class="w-full">
<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-green-500" data-i18n="colors.green">Green</option>
<option value="bg-yellow-500" data-i18n="colors.yellow">Yellow</option>
<option value="bg-purple-500" data-i18n="colors.purple">Purple</option>
<option value="bg-pink-500" data-i18n="colors.pink">Pink</option>
<option value="bg-indigo-500" data-i18n="colors.indigo">Indigo</option>
<option value="bg-gray-800" data-i18n="colors.dark">Dark</option>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.price">Price (Rp)</label>
<input type="number" name="price" class="w-full" placeholder="5000">
</div>
<div>
<label class="form-label" data-i18n="quick_print.selling_price">Selling Price</label>
<input type="number" name="selling_price" class="w-full" placeholder="Default same">
</div>
<div>
<label class="form-label" data-i18n="quick_print.prefix">Prefix</label>
<input type="text" name="prefix" class="w-full" placeholder="Example: VIP-">
</div>
<div>
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
<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="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>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.time_limit">Time Limit</label>
<input type="text" name="time_limit" class="w-full" placeholder="3h">
</div>
<div>
<label class="form-label" data-i18n="quick_print.data_limit">Data Limit</label>
<input type="text" name="data_limit" class="w-full" placeholder="500M (Optional)">
</div>
<div class="col-span-1 md:col-span-2">
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
<input type="text" name="comment" class="w-full" placeholder="Description or Note">
</div>
</div> </div>
</form>
<form action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="p-6 space-y-4"> </template>
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<!-- Quick Inputs Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="col-span-1 md:col-span-2">
<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">
</div>
<div>
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
<select name="profile" class="custom-select w-full" data-search="true">
<?php foreach($profiles as $p): ?>
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
<select name="color" class="custom-select w-full">
<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-green-500" data-i18n="colors.green">Green</option>
<option value="bg-yellow-500" data-i18n="colors.yellow">Yellow</option>
<option value="bg-purple-500" data-i18n="colors.purple">Purple</option>
<option value="bg-pink-500" data-i18n="colors.pink">Pink</option>
<option value="bg-indigo-500" data-i18n="colors.indigo">Indigo</option>
<option value="bg-gray-800" data-i18n="colors.dark">Dark</option>
</select>
</div>
<div>
<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">
</div>
<div>
<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">
</div>
<div>
<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-">
</div>
<div>
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
<select name="char_length" class="custom-select w-full">
<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="8" data-i18n="common.char_length" data-i18n-params='{"n": 8}'>8 Characters</option>
</select>
</div>
<div>
<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">
</div>
<div>
<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)">
</div>
<div class="col-span-1 md:col-span-2">
<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">
</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>
</div>
</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');
function closeModal() { // Update Route Logic Here if needed, or rely on Hidden ID
overlay.classList.add('opacity-0'); // For now backend handles update if ID is present
content.classList.remove('open'); form.action = "/<?= htmlspecialchars($session) ?>/quick-print/update";
setTimeout(() => { // Populate inputs
overlay.classList.add('hidden'); form.querySelector('[name="id"]').value = row.dataset.id;
}, 300); 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';
}
// Pass callbacks to helper
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -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;
}); };
}
function closeModal(id) { const onOpenedFn = (popup) => {
const modal = document.getElementById(id); const form = popup.querySelector('#cors-form');
const content = modal.querySelector('.modal-content'); if (isEdit) {
modal.classList.add('opacity-0'); form.action = '/settings/api-cors/update';
content.classList.remove('scale-100', 'opacity-100'); form.querySelector('[name="id"]').value = row.dataset.ruleId;
content.classList.add('scale-95', 'opacity-0'); form.querySelector('[name="origin"]').value = row.dataset.origin;
setTimeout(() => { modal.classList.add('hidden'); }, 300); form.querySelector('[name="headers"]').value = row.dataset.headers;
} form.querySelector('[name="max_age"]').value = row.dataset.maxAge;
function editRule(rule) { const methods = JSON.parse(row.dataset.methods || '[]');
document.getElementById('edit_id').value = rule.id; form.querySelectorAll('[name="methods[]"]').forEach(cb => {
document.getElementById('edit_origin').value = rule.origin; cb.checked = methods.includes(cb.value);
document.getElementById('edit_headers').value = rule.headers_arr.join(', '); });
document.getElementById('edit_max_age').value = rule.max_age; }
};
// Clear and check checkboxes Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
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'; ?>

View File

@@ -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'; ?>

View File

@@ -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'; ?>

View File

@@ -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">

View File

@@ -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; ?>

View File

@@ -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">

View File

@@ -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'; ?>

View File

@@ -0,0 +1,10 @@
VERSION=latest
CONTAINER_NAME=mivo
HOST_IP=0.0.0.0
APP_PORT=8085
APP_PATH=/www/dk_project/mivo
APP_ENV=production
APP_DEBUG=false
TZ=Asia/Jakarta
CPUS=1.0
MEMORY_LIMIT=512M

View File

@@ -0,0 +1,36 @@
version: '3.8'
services:
mivo:
image: dyzulk/mivo:${VERSION:-latest}
container_name: ${CONTAINER_NAME:-mivo}
restart: unless-stopped
ports:
- "${HOST_IP:-0.0.0.0}:${APP_PORT:-8085}:80"
volumes:
# Database & Sessions
- ${APP_PATH:-.}/mivo_data:/var/www/html/app/Database
# Custom Logos
- ${APP_PATH:-.}/mivo_logos:/var/www/html/public/assets/img/logos
# Environment file (Optional - mapped from host)
# - ${APP_PATH:-.}/.env:/var/www/html/.env
environment:
- APP_ENV=${APP_ENV:-production}
- APP_DEBUG=${APP_DEBUG:-false}
- TZ=${TZ:-Asia/Jakarta}
deploy:
resources:
limits:
cpus: '${CPUS:-1.0}'
memory: ${MEMORY_LIMIT:-512M}
networks:
- mivo_net
networks:
mivo_net:
driver: bridge

View File

@@ -8,11 +8,35 @@ export default defineConfig({
lang: 'en-US', lang: 'en-US',
cleanUrls: true, cleanUrls: true,
lastUpdated: true, lastUpdated: true,
sitemap: {
hostname: 'https://docs.mivo.dyzulk.com'
},
head: [ head: [
['link', { rel: 'icon', href: '/logo-m.svg' }] ['link', { rel: 'icon', href: '/logo-m.svg' }],
['meta', { name: 'theme-color', content: '#0ea5e9' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: 'MIVO' }],
['meta', { property: 'og:image', content: 'https://docs.mivo.dyzulk.com/og-image.png' }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
['meta', { name: 'twitter:image', content: 'https://docs.mivo.dyzulk.com/og-image.png' }],
['meta', { name: 'twitter:site', content: '@dyzulkdev' }]
], ],
transformHead: ({ pageData }) => {
const title = pageData.title ? `${pageData.title} | MIVO` : 'MIVO'
const description = pageData.description || "Modern Mikrotik Voucher Management System"
const url = `https://docs.mivo.dyzulk.com/${pageData.relativePath.replace(/((^|\/)index)?\.md$/, '$2')}`
return [
['meta', { property: 'og:title', content: title }],
['meta', { property: 'og:description', content: description }],
['meta', { property: 'og:url', content: url }],
['meta', { name: 'twitter:title', content: title }],
['meta', { name: 'twitter:description', content: description }],
]
},
// Shared theme config // Shared theme config
themeConfig: { themeConfig: {
logo: { logo: {
@@ -27,7 +51,7 @@ export default defineConfig({
footer: { footer: {
message: 'Released under the MIT License.', message: 'Released under the MIT License.',
copyright: 'Copyright © 2026 DyzulkDev' copyright: `Copyright © 2026${new Date().getFullYear() > 2026 ? ' - ' + new Date().getFullYear() : ''} DyzulkDev`
}, },
search: { search: {

View File

@@ -8,7 +8,9 @@ export const navEn: DefaultTheme.NavItem[] = [
text: 'Community', text: 'Community',
items: [ items: [
{ text: 'Changelog', link: 'https://github.com/dyzulk/mivo/releases' }, { text: 'Changelog', link: 'https://github.com/dyzulk/mivo/releases' },
{ text: 'Contributing', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' } { text: 'Contributing', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
{ text: 'Forum (Issues)', link: 'https://github.com/dyzulk/mivo/issues' },
{ text: 'Star on GitHub', link: 'https://github.com/dyzulk/mivo/stargazers' }
] ]
} }
] ]
@@ -21,7 +23,9 @@ export const navId: DefaultTheme.NavItem[] = [
text: 'Komunitas', text: 'Komunitas',
items: [ items: [
{ text: 'Catatan Rilis', link: 'https://github.com/dyzulk/mivo/releases' }, { text: 'Catatan Rilis', link: 'https://github.com/dyzulk/mivo/releases' },
{ text: 'Kontribusi', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' } { text: 'Kontribusi', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
{ text: 'Forum (Issues)', link: 'https://github.com/dyzulk/mivo/issues' },
{ text: 'Beri Bintang', link: 'https://github.com/dyzulk/mivo/stargazers' }
] ]
} }
] ]

View File

@@ -17,6 +17,7 @@ export const sidebarEn: DefaultTheme.Sidebar = {
collapsed: false, collapsed: false,
items: [ items: [
{ text: 'Docker', link: '/guide/docker' }, { text: 'Docker', link: '/guide/docker' },
{ text: 'aaPanel (Docker)', link: '/guide/docker-aapanel' },
{ text: 'Web Server', link: '/guide/installation#web-servers' }, { text: 'Web Server', link: '/guide/installation#web-servers' },
{ text: 'Shared Hosting', link: '/guide/installation#shared-hosting' }, { text: 'Shared Hosting', link: '/guide/installation#shared-hosting' },
{ text: 'VPS & Cloud', link: '/guide/installation#vps-cloud' }, { text: 'VPS & Cloud', link: '/guide/installation#vps-cloud' },
@@ -88,6 +89,7 @@ export const sidebarId: DefaultTheme.Sidebar = {
collapsed: false, collapsed: false,
items: [ items: [
{ text: 'Docker', link: '/id/guide/docker' }, { text: 'Docker', link: '/id/guide/docker' },
{ text: 'aaPanel (Docker)', link: '/id/guide/docker-aapanel' },
{ text: 'Web Server', link: '/id/guide/installation#web-server' }, { text: 'Web Server', link: '/id/guide/installation#web-server' },
{ text: 'Shared Hosting', link: '/id/guide/installation#shared-hosting' }, { text: 'Shared Hosting', link: '/id/guide/installation#shared-hosting' },
{ text: 'VPS & Cloud', link: '/id/guide/installation#vps-cloud' }, { text: 'VPS & Cloud', link: '/id/guide/installation#vps-cloud' },

View File

@@ -0,0 +1,104 @@
# Deploy on aaPanel (Docker)
aaPanel makes it easiest to manage Docker projects using its native **Docker Manager** module. We recommend following the standard "Quick Install" pattern which uses environment variables for better maintainability.
::: tip PREREQUISITES
Ensure you have the **Docker** module installed from the aaPanel App Store.
:::
## 1. Prepare Configuration
We use a "Native aaPanel Style" setup where configuration is separated from the template.
**Environment File (`.env`)**
Create a new file named `.env` and configure your preferences:
```ini
VERSION=latest
CONTAINER_NAME=mivo
HOST_IP=0.0.0.0
APP_PORT=8085
APP_PATH=/www/dk_project/mivo
APP_ENV=production
APP_DEBUG=false
TZ=Asia/Jakarta
CPUS=1.0
MEMORY_LIMIT=512M
```
**Attribute Explanation:**
- `APP_PATH`: **Crucial**. This must match your project directory in aaPanel (default is `/www/dk_project/<project_name>`).
- `APP_PORT`: The port you want to expose (default `8080`).
- `TZ`: Your timezone (e.g., `Asia/Jakarta`).
## 2. Create Project in aaPanel
1. Login to your aaPanel dashboard.
2. Navigate to **Docker** > **Project**.
3. Click **Add Project**.
4. Fill in the details:
- **Name**: `mivo` (or your preferred name)
- **Path**: This will auto-fill (usually `/www/dk_project/mivo`). **Update `APP_PATH` in your .env to match this!**
- **Compose Template**: Paste the following YAML content:
```yaml
version: '3.8'
services:
mivo:
image: dyzulk/mivo:${VERSION:-latest}
container_name: ${CONTAINER_NAME:-mivo}
restart: unless-stopped
ports:
- "${HOST_IP:-0.0.0.0}:${APP_PORT:-8085}:80"
volumes:
# Database & Sessions
- ${APP_PATH:-.}/mivo_data:/var/www/html/app/Database
# Custom Logos
- ${APP_PATH:-.}/mivo_logos:/var/www/html/public/assets/img/logos
# Environment file (Optional - mapped from host)
# - ${APP_PATH:-.}/.env:/var/www/html/.env
environment:
- APP_ENV=${APP_ENV:-production}
- APP_DEBUG=${APP_DEBUG:-false}
- TZ=${TZ:-Asia/Jakarta}
deploy:
resources:
limits:
cpus: '${CPUS:-1.0}'
memory: ${MEMORY_LIMIT:-512M}
networks:
- mivo_net
networks:
mivo_net:
driver: bridge
```
5. **Wait!** Before clicking "Confirm" or "Add":
- Look for the **.env** config section (usually a tab or sidebar in the Add Project modal).
- Paste your `.env` content there.
6. Click **Confirm** to deploy.
## 3. Verify Deployment
aaPanel will pull the image and start the container. Once running:
- **Database Location**: Your data is safe at `/www/dk_project/mivo/mivo_data`.
- **Logos Location**: Upload custom logos to `/www/dk_project/mivo/mivo_logos`.
## 4. Setup Domain (Reverse Proxy)
To access MIVO via a domain (e.g., `mivo.yourdomain.com`):
1. Go to **Website** > **Add Site**.
2. Enter your domain name.
3. For **PHP Version**, select **Reverse Proxy** (or create as Static and set up proxy later).
4. After creation, open the site settings > **Reverse Proxy** > **Add Reverse Proxy**.
5. **Target URL**: `http://127.0.0.1:8085` (Replace `8085` with your `APP_PORT`).
6. Save and secure with SSL.

View File

@@ -8,7 +8,7 @@ Welcome to the MIVO Guide. This section will help you understand what MIVO is an
## <Icon name="Zap" color="warning" /> What is MIVO? ## <Icon name="Zap" color="warning" /> What is MIVO?
MIVO is a modern, lightweight Mikrotik Voucher Management system. It's a complete rewrite of the legendary Mikhmon v3, re-engineered for better performance and a premium user experience. MIVO is a modern, lightweight Mikrotik Voucher Management system. It is designed to be efficient, fast, and user-friendly, providing a seamless experience for Hotspot management.
## <Icon name="BookOpen" color="primary" /> Navigation ## <Icon name="BookOpen" color="primary" /> Navigation

View File

@@ -0,0 +1,104 @@
# Deploy di aaPanel (Docker)
aaPanel adalah salah satu panel hosting paling populer yang memiliki modul **Docker Manager** yang sangat memudahkan manajemen container. Kami merekomendasikan penggunaan gaya "Native" aaPanel agar manajemen resource dan env lebih rapi.
::: tip PRASYARAT
Pastikan Anda sudah menginstall modul **Docker** dari App Store di dalam aaPanel Anda.
:::
## 1. Siapkan Konfigurasi
Metode ini memisahkan konfigurasi (di file `.env`) dari template logic, sehingga Anda bisa dengan mudah mengubah port atau resource limit tanpa mengedit file YAML yang rumit.
**File Environment (`.env`)**
Buat file baru bernama `.env` (atau simpan teks ini untuk nanti):
```ini
VERSION=latest
CONTAINER_NAME=mivo
HOST_IP=0.0.0.0
APP_PORT=8085
APP_PATH=/www/dk_project/mivo
APP_ENV=production
APP_DEBUG=false
TZ=Asia/Jakarta
CPUS=1.0
MEMORY_LIMIT=512M
```
**Penjelasan Atribut:**
- `APP_PATH`: **Penting**. Ini harus sama persis dengan lokasi project di aaPanel Anda (defaultnya `/www/dk_project/<nama_project>`).
- `APP_PORT`: Port host yang ingin dibuka (default `8080`).
- `CPUS` & `MEMORY_LIMIT`: Batasan resource agar container tidak membebani server/VPS Anda.
## 2. Buat Project di aaPanel
1. Login ke dashboard aaPanel.
2. Masuk ke menu **Docker** > **Project** (atau **Compose** di versi lama).
3. Klik tombol **Add Project**.
4. Isi form sebagai berikut:
- **Name**: `mivo` (atau nama lain yang Anda suka)
- **Path**: Perhatikan path yang muncul otomatis (biasanya `/www/dk_project/mivo`). **Pastikan `APP_PATH` di .env Anda sesuai dengan path ini!**
- **Compose Template**: Copy-paste kode YAML berikut:
```yaml
version: '3.8'
services:
mivo:
image: dyzulk/mivo:${VERSION:-latest}
container_name: ${CONTAINER_NAME:-mivo}
restart: unless-stopped
ports:
- "${HOST_IP:-0.0.0.0}:${APP_PORT:-8085}:80"
volumes:
# Database & Sessions
- ${APP_PATH:-.}/mivo_data:/var/www/html/app/Database
# Custom Logos
- ${APP_PATH:-.}/mivo_logos:/var/www/html/public/assets/img/logos
# Environment file (Optional - mapped from host)
# - ${APP_PATH:-.}/.env:/var/www/html/.env
environment:
- APP_ENV=${APP_ENV:-production}
- APP_DEBUG=${APP_DEBUG:-false}
- TZ=${TZ:-Asia/Jakarta}
deploy:
resources:
limits:
cpus: '${CPUS:-1.0}'
memory: ${MEMORY_LIMIT:-512M}
networks:
- mivo_net
networks:
mivo_net:
driver: bridge
```
5. **Tunggu!** Sebelum klik "Confirm":
- Cari bagian konfigurasi **.env** (biasanya berupa tab atau input area di samping/bawah editor YAML).
- Paste konten `.env` yang sudah Anda siapkan di langkah 1 ke sana.
6. Klik **Confirm** untuk memulai deployment.
## 3. Verifikasi Deployment
aaPanel akan otomatis mendownload image dan menjalankan container.
- **Lokasi Data**: Database Anda aman tersimpan di `/www/dk_project/mivo/mivo_data`. folder ini tidak akan hilang walau container dihapus.
- **Lokasi Logo**: Upload logo kustom Anda ke `/www/dk_project/mivo/mivo_logos`.
## 4. Setup Domain (Reverse Proxy)
Agar MIVO bisa diakses menggunakan domain (contoh: `mivo.domainanda.com`):
1. Ke menu **Website** > **Add Site**.
2. Masukkan nama domain Anda.
3. Pada **PHP Version**, pilih **Static** (atau langsung Reverse Proxy jika ada opsinya).
4. Setelah site dibuat, buka settingannya > **Reverse Proxy** > **Add Reverse Proxy**.
5. **Target URL**: `http://127.0.0.1:8085` (Ganti `8085` sesuai dengan `APP_PORT` Anda).
6. Simpan dan aktifkan SSL agar lebih aman.

View File

@@ -8,7 +8,7 @@ Selamat datang di Panduan MIVO. Bagian ini akan membantu Anda memahami apa itu M
## <Icon name="Zap" color="warning" /> Apa itu MIVO? ## <Icon name="Zap" color="warning" /> Apa itu MIVO?
MIVO adalah sistem Manajemen Voucher Mikrotik yang modern dan ringan. MIVO merupakan penulisan ulang total dari Mikhmon v3 yang legendaris, dirancang ulang untuk performa yang lebih baik dan pengalaman pengguna yang premium. MIVO adalah sistem Manajemen Voucher Mikrotik yang modern dan ringan. Sistem ini dirancang agar efisien, cepat, dan mudah digunakan, memberikan pengalaman yang mulus untuk pengelolaan Hotspot.
## <Icon name="BookOpen" color="primary" /> Navigasi ## <Icon name="BookOpen" color="primary" /> Navigasi

View File

@@ -30,7 +30,7 @@ features:
## Mengapa MIVO? ## Mengapa MIVO?
MIVO adalah penulisan ulang total dari **Mikhmon v3** yang legendaris, direkayasa ulang untuk memecahkan masalah performa umum pada perangkat keras spesifikasi rendah. MIVO adalah **Sistem Manajemen Voucher Mikrotik** generasi baru, dirancang untuk memberikan pengalaman pengguna premium bahkan pada perangkat keras spesifikasi rendah.
### Sorotan Utama ### Sorotan Utama

View File

@@ -30,7 +30,7 @@ features:
## Why MIVO? ## Why MIVO?
MIVO is a complete rewrite of the legendary **Mikhmon v3**, re-engineered to solve common performance issues on low-end hardware. MIVO is a next-generation **Mikrotik Voucher Management System**, engineered to deliver premium user experience even on low-end hardware.
### Key Highlights ### Key Highlights

BIN
docs/public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -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": {

View File

@@ -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);
} }

View File

@@ -1,94 +1,107 @@
class SimpleDataTable { /**
constructor(tableSelector, options = {}) { * Mivo Component: Datatable
* A simple, lightweight, client-side datatable.
*/
class DataTable {
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.reTranslate();
this.init(); this.render();
});
} }
// Listen for language change // Wait for I18n readiness if available
window.addEventListener('languageChanged', () => { if (window.i18n && window.i18n.ready) {
this.reTranslate(); window.i18n.ready.then(() => this.init());
this.render(); } 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;
} }

View 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));
});
}

View File

@@ -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
View 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();

View File

@@ -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.
}

View File

@@ -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();
} }

View 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());
}

View File

@@ -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';
}
});
}
});

View File

@@ -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"
} }
} }

View File

@@ -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"
} }
} }

View File

@@ -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']);

View File

@@ -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

View File

@@ -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);
}