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

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

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
.env
node_modules
deploy_package.tar.gz
temp_debug
*.md
docker-compose.yml
docs/
app/Database/*.sqlite

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
APP_NAME=MIVO
APP_ENV=production
APP_KEY=mikhmonv3remake_secret_key_32bytes
APP_DEBUG=true
# Database
DB_PATH=/app/Database/database.sqlite

77
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Docker Build & Publish
on:
push:
branches: [ "main", "master" ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main", "master" ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: docker.io
# github.repository as <account>/<repo>
IMAGE_NAME: dyzulk/mivo
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Branch main -> dyzulk/mivo:edge
type=raw,value=edge,enable={{is_default_branch}}
# Tag v1.0.0 -> dyzulk/mivo:1.0.0
type=ref,event=tag
# Tag v1.0.0 -> dyzulk/mivo:latest
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Update Docker Hub Description (README)
# https://github.com/peter-evans/dockerhub-description
- name: Update Docker Hub Description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: ${{ env.IMAGE_NAME }}
short-description: ${{ github.event.repository.description }}
readme-filepath: ./README.md

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Dependencies
/node_modules
/vendor
# System Files
.DS_Store
Thumbs.db
.vscode/
.idea/
# Application Data
*.log
/app/Database/*.sqlite
/temp_debug/
*.bak
# Build Artifacts & Deployments
/deploy_package.tar.gz
/mivo_backup_*.mivo
# Secrets and Environment
.env

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM php:8.2-fpm-alpine
# Install system dependencies
RUN apk add --no-cache \
nginx \
supervisor \
sqlite-dev \
libzip-dev \
zip \
unzip
# Install PHP extensions
RUN docker-php-ext-install pdo_sqlite zip
# Configure Nginx
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
# Set working directory
WORKDIR /var/www/html
# Copy application files
COPY . /var/www/html
# Create Database directory explicitly & Set Permissions
RUN mkdir -p /var/www/html/app/Database && \
chown -R www-data:www-data /var/www/html && \
chmod -R 755 /var/www/html
# Expose port
EXPOSE 80
# Start Supervisor (which starts Nginx & PHP-FPM)
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DyzulkDev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
<p align="center">
<img src="public/assets/img/logo.png" alt="MIVO Logo" width="200" />
</p>
# MIVO (Mikrotik Voucher)
> **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.
![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)
## 🚀 Key Features
* **⚡ Lightweight Core**: Built on a custom minimal MVC framework (~50KB core) optimized for speed.
* **🎨 Modern UI/UX**: Fresh Glassmorphism design system using TailwindCSS and Alpine.js.
* **📱 Responsive**: Fully optimized mobile experience with touch-friendly navigation.
* **🔒 Secure**: Environment-based configuration (`.env`), encrypted credentials, and secure session management.
* **🔌 API Ready**: Built-in REST API support with CORS management for external integrations.
* **🛠️ CLI Tool**: Includes `mivo` CLI helper for easy management and installation.
## 🛠️ Installation
### Requirements
* PHP 8.0 or higher
* SQLite3 Extension
* OpenSSL Extension
### Quick Start
1. **Clone the Repository**
```bash
git clone https://github.com/dyzulk/mivo.git
cd mivo
```
2. **Setup Environment**
```bash
cp .env.example .env
```
3. **Install & Generate Key**
```bash
php mivo install
```
*This will create the database, run migrations, generate your secure `APP_KEY`, and set up the admin account.*
4. **Run Development Server**
```bash
php mivo serve
```
Access the app at `http://localhost:8000`.
## 📂 Structure
* `app/` - Core application logic (Controllers, Models, Views).
* `public/` - Web root and assets.
* `routes/` - Route definitions (`web.php`, `api.php`).
* `mivo` - CLI executable entry point.
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## ☕ Support the Project
If you find MIVO useful, please consider supporting its development. Your contribution helps keep the project alive!
[![SociaBuzz Tribe](https://img.shields.io/badge/SociaBuzz-Tribe-green?style=for-the-badge&logo=sociabuzz&logoColor=white)](https://sociabuzz.com/dyzulkdev/tribe)
## 📄 License
This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
---
*Created with ❤️ by DyzulkDev*

36
app/Config/SiteConfig.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
namespace App\Config;
class SiteConfig {
const APP_NAME = 'MIVO';
const APP_VERSION = 'v1.0';
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
const CREDIT_NAME = 'DyzulkDev';
const CREDIT_URL = 'https://dyzulk.com';
const YEAR = '2026';
const REPO_URL = 'https://github.com/dyzulk/mivo';
// Security Keys
// Fetched from .env or fallback to default
public static function getSecretKey() {
return getenv('APP_KEY') ?: 'mikhmonv3remake_secret_key_32bytes';
}
const IS_DEV = true; // Still useful for code logic not relying on env yet, or can be refactored too.
/**
* Get the formatted page title
*/
public static function getTitle($page = '') {
return empty($page) ? self::APP_NAME : $page . ' | ' . self::APP_NAME;
}
/**
* Get footer text
*/
public static function getFooter() {
$currentYear = date('Y');
$yearDisplay = (self::YEAR == $currentYear) ? self::YEAR : self::YEAR . ' - ' . $currentYear;
return self::APP_FULL_NAME . ' &copy; 2026 - ' . $yearDisplay . ' &bull; Created with Love <i data-lucide="heart" class="w-3 h-3 inline text-red-500 fill-red-500 mx-1"></i> Developed by <a href="' . self::CREDIT_URL . '" target="_blank" class="font-medium hover:text-foreground transition-colors">' . self::CREDIT_NAME . '</a>';
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Libraries\RouterOSAPI;
use App\Models\Config;
use App\Helpers\EncryptionHelper;
class ApiController extends Controller {
public function getInterfaces() {
// Only allow POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method Not Allowed']);
return;
}
// Get JSON Input
$input = json_decode(file_get_contents('php://input'), true);
$ip = $input['ip'] ?? '';
$user = $input['user'] ?? '';
$pass = $input['password'] ?? '';
$id = $input['id'] ?? null;
$port = $input['port'] ?? 8728; // Default port
// Fallback to stored password if empty and ID provided (Edit Mode)
if (empty($pass) && !empty($id)) {
$configModel = new Config();
$session = $configModel->getSessionById($id);
if ($session && !empty($session['password'])) {
$pass = EncryptionHelper::decrypt($session['password']);
}
}
if (empty($ip) || empty($user)) {
http_response_code(400);
echo json_encode(['error' => 'IP Address and Username are required']);
return;
}
$api = new RouterOSAPI();
// $api->debug = true; // Enable for debugging
$api->port = (int)$port;
if ($api->connect($ip, $user, $pass)) {
$api->write('/interface/print');
$read = $api->read(false);
$interfaces = $api->parseResponse($read);
$api->disconnect();
$list = [];
foreach ($interfaces as $iface) {
if (isset($iface['name'])) {
$list[] = $iface['name'];
}
}
// Return success
echo json_encode([
'success' => true,
'interfaces' => $list
]);
} else {
http_response_code(500);
echo json_encode([
'error' => 'Connection failed. Check IP, User, Password, or connectivity.'
]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\User;
class AuthController extends Controller {
public function showLogin() {
if (isset($_SESSION['user_id'])) {
header('Location: /');
exit;
}
return $this->view('login');
}
public function login() {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$userModel = new User();
$user = $userModel->attempt($username, $password);
if ($user) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
\App\Helpers\FlashHelper::set('success', 'Welcome Back', 'Login successful.');
header('Location: /');
exit;
} else {
\App\Helpers\FlashHelper::set('error', 'Login Failed', 'Invalid credentials');
header('Location: /login');
exit;
}
}
public function logout() {
session_destroy();
header('Location: /login');
exit;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
use App\Core\Middleware;
class DashboardController extends Controller {
public function __construct() {
Middleware::auth();
}
public function index($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
echo "Session not found.";
return;
}
// Mock Data for Demo (SQLite or Legacy)
if ($session === 'demo') {
$data = [
'session' => $session,
'clock' => ['time' => '12:00:00', 'date' => 'jan/01/2024'],
'resource' => [
'board-name' => 'CHR (Demo SQLite)',
'version' => '7.12',
'uptime' => '1w 2d 3h',
'cpu-load' => '15',
'free-memory' => 1048576 * 512, // 512 MB
'free-hdd-space' => 1048576 * 1024, // 1 GB
],
// ... rest of mock data
'routerboard' => ['model' => 'x86_64'],
'hotspot_active' => 25,
'hotspot_users' => 150,
'lang' => [
'system_date_time' => 'System Date & Time',
'uptime' => 'Uptime',
'board_name' => 'Board Name',
'model' => 'Model',
'cpu_load' => 'CPU Load',
'free_memory' => 'Free Memory',
'free_hdd' => 'Free HDD',
'hotspot_active' => 'Hotspot Active',
'hotspot_users' => 'Hotspot Users',
]
];
return $this->view('dashboard', $data);
}
$API = new RouterOSAPI();
// Determine password: if legacy, decrypt it. If SQLite (new), assume plain for now
// (since we just seeded 'admin' plain in setup_database.php) or decrypt if you decide to encrypt in DB.
// For this Demo, setup_database.php inserted plain 'admin'.
// Existing v3 passwords are encrypted.
$password = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password = RouterOSAPI::decrypt($password);
}
if ($API->connect($creds['ip'], $creds['user'], $password)) {
// ... API calls
$getclock = $API->comm("/system/clock/print");
$clock = $getclock[0] ?? [];
$getresource = $API->comm("/system/resource/print");
$resource = $getresource[0] ?? [];
$getrouterboard = $API->comm("/system/routerboard/print");
$routerboard = $getrouterboard[0] ?? [];
$counthotspotactive = $API->comm("/ip/hotspot/active/print", array("count-only" => ""));
$countallusers = $API->comm("/ip/hotspot/user/print", array("count-only" => ""));
$API->disconnect();
$data = [
'session' => $session,
'clock' => $clock,
'resource' => $resource,
'routerboard' => $routerboard,
'hotspot_active' => $counthotspotactive,
'hotspot_users' => $countallusers,
'lang' => [
'system_date_time' => 'System Date & Time',
'uptime' => 'Uptime',
'board_name' => 'Board Name',
'model' => 'Model',
'cpu_load' => 'CPU Load',
'free_memory' => 'Free Memory',
'free_hdd' => 'Free HDD',
'hotspot_active' => 'Hotspot Active',
'hotspot_users' => 'Hotspot Users',
'hotspot_users' => 'Hotspot Users',
],
'interface' => $creds['interface'] ?? 'ether1'
];
// Pass Users Link (Optional: could be part of layout or card link)
// Ideally, the "Hotspot Users" card on dashboard should be clickable.
return $this->view('dashboard', $data);
} else {
echo "Connection Failed to " . $creds['ip'];
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
use App\Helpers\HotspotHelper;
class DhcpController extends Controller
{
public function index($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
header('Location: /');
exit;
}
$leases = [];
$API = new RouterOSAPI();
$API->attempts = 1;
$API->timeout = 3;
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
// Fetch DHCP Leases
$leases = $API->comm("/ip/dhcp-server/lease/print");
$API->disconnect();
}
// Add index for viewing
return $this->view('network/dhcp', [
'session' => $session,
'leases' => $leases ?? []
]);
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
class GeneratorController extends Controller {
public function index($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
$this->redirect('/');
return;
}
$API = new RouterOSAPI();
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
// Fetch Profiles for Dropdown
$profiles = $API->comm('/ip/hotspot/user/profile/print');
// Fetch Hotspot Servers
$servers = $API->comm('/ip/hotspot/print');
$API->disconnect();
$data = [
'session' => $session,
'title' => 'Generate Vouchers - ' . $session,
'profiles' => $profiles,
'servers' => $servers
];
$this->view('hotspot/generate', $data);
} else {
// Handle connection error (flash message ideally, but for now redirect or show error)
echo "Connection failed to " . $creds['ip'];
}
}
public function process() {
$session = $_POST['session'] ?? '';
$qty = intval($_POST['qty'] ?? 1);
$server = $_POST['server'] ?? 'all';
$userMode = $_POST['userModel'] ?? 'up';
$userLength = intval($_POST['userLength'] ?? 4);
$prefix = $_POST['prefix'] ?? '';
$char = $_POST['char'] ?? 'mix';
$profile = $_POST['profile'] ?? '';
$comment = $_POST['comment'] ?? '';
// Time Limit Logic (d, h, m)
$timelimit_d = $_POST['timelimit_d'] ?? '';
$timelimit_h = $_POST['timelimit_h'] ?? '';
$timelimit_m = $_POST['timelimit_m'] ?? '';
$timeLimit = '';
if ($timelimit_d != '') $timeLimit .= $timelimit_d . 'd';
if ($timelimit_h != '') $timeLimit .= $timelimit_h . 'h';
if ($timelimit_m != '') $timeLimit .= $timelimit_m . 'm';
// Data Limit Logic (Value, Unit)
$datalimit_val = $_POST['datalimit_val'] ?? '';
$datalimit_unit = $_POST['datalimit_unit'] ?? 'MB';
$dataLimit = '';
if (!empty($datalimit_val) && is_numeric($datalimit_val)) {
$bytes = (float)$datalimit_val;
if ($datalimit_unit === 'GB') {
$bytes = $bytes * 1073741824;
} else {
// MB
$bytes = $bytes * 1048576;
}
$dataLimit = (string)round($bytes);
}
if (!$session || $qty < 1 || !$profile) {
$this->back($session);
return;
}
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
$this->redirect('/');
return;
}
$API = new RouterOSAPI();
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
// Format Comment: prefix-rand-date- comment
// Example: up-123-12.01.26- premium
$commentPrefix = ($userMode === 'vc') ? 'vc-' : 'up-';
$batchId = rand(100, 999);
$date = date('m.d.y');
$commentBody = $comment ?: $profile;
$finalComment = "{$commentPrefix}{$batchId}-{$date}- {$commentBody}";
for ($i = 0; $i < $qty; $i++) {
$username = $prefix . $this->generateRandomString($userLength, $char);
$password = $username;
if ($userMode === 'up') {
$password = $this->generateRandomString($userLength, $char);
}
$user = [
'server' => $server,
'profile' => $profile,
'name' => $username,
'password' => $password,
'comment' => $finalComment
];
if (!empty($timeLimit)) $user['limit-uptime'] = $timeLimit;
if (!empty($dataLimit)) $user['limit-bytes-total'] = $dataLimit;
$API->comm("/ip/hotspot/user/add", $user);
}
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.vouchers_generated', 'toasts.vouchers_generated_desc', ['qty' => $qty], true);
$this->redirect('/' . $session . '/hotspot/users');
}
private function generateRandomString($length, $charType) {
$characters = '';
switch ($charType) {
case 'lower':
$characters = 'abcdefghijklmnopqrstuvwxyz';
break;
case 'upper':
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 'number':
$characters = '0123456789';
break;
case 'uppernumber':
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
break;
case 'lowernumber':
$characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
break;
case 'mix':
default:
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
break;
}
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[rand(0, strlen($characters) - 1)];
}
return $randomString;
}
private function back($session) {
$this->redirect('/' . $session . '/hotspot/generate');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
class HomeController extends Controller {
public function __construct() {
\App\Core\Middleware::auth();
}
public function index() {
// Fetch real router sessions from Config model
$config = new \App\Models\Config();
$routers = $config->getAllSessions();
$data = [
'routers' => $routers
];
$this->view('home', $data);
}
public function designSystem() {
$data = ['title' => 'MIVO - Design System'];
$this->view('design_system', $data);
}
public function testAlert() {
\App\Helpers\FlashHelper::set('success', 'toasts.test_alert', 'toasts.test_alert_desc', [], true);
header("Location: /");
exit;
}
}

View File

@@ -0,0 +1,864 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
use App\Models\VoucherTemplateModel;
use App\Core\Middleware;
class HotspotController extends Controller {
public function __construct() {
Middleware::auth();
}
public function index($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
echo "Session not found.";
return;
}
$userId = $session; // For view context
$users = [];
$error = null;
$API = new RouterOSAPI();
//$API->debug = true; // Enable for debugging
// Decrypt password if from SQLite
$password = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password = RouterOSAPI::decrypt($password);
}
if ($API->connect($creds['ip'], $creds['user'], $password)) {
// Get all hotspot users
$users = $API->comm("/ip/hotspot/user/print");
// Get active users to mark status (optional, can be done later for optimization)
// $active = $API->comm("/ip/hotspot/active/print");
$API->disconnect();
} else {
$error = "Connection Failed to " . $creds['ip'];
}
$data = [
'session' => $session,
'users' => $users,
'error' => $error
];
return $this->view('hotspot/users/users', $data);
}
public function add($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return; // Should handle error better
$API = new RouterOSAPI();
$password = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password = RouterOSAPI::decrypt($password);
}
$profiles = [];
if ($API->connect($creds['ip'], $creds['user'], $password)) {
$profiles = $API->comm("/ip/hotspot/user/profile/print");
$API->disconnect();
}
$data = [
'session' => $session,
'profiles' => $profiles
];
return $this->view('hotspot/users/add', $data);
}
public function store() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$name = $_POST['name'] ?? '';
$password_user = $_POST['password'] ?? '';
$profile = $_POST['profile'] ?? 'default';
$comment = $_POST['comment'] ?? '';
// Time Limit Logic (d, h, m)
$timelimit_d = $_POST['timelimit_d'] ?? '';
$timelimit_h = $_POST['timelimit_h'] ?? '';
$timelimit_m = $_POST['timelimit_m'] ?? '';
$timelimit = '';
if ($timelimit_d != '') $timelimit .= $timelimit_d . 'd';
if ($timelimit_h != '') $timelimit .= $timelimit_h . 'h';
if ($timelimit_m != '') $timelimit .= $timelimit_m . 'm';
// Data Limit Logic (Value, Unit)
$datalimit_val = $_POST['datalimit_val'] ?? '';
$datalimit_unit = $_POST['datalimit_unit'] ?? 'MB';
$datalimit = '';
if (!empty($datalimit_val) && is_numeric($datalimit_val)) {
$bytes = (int)$datalimit_val;
if ($datalimit_unit === 'GB') {
$bytes = $bytes * 1073741824;
} else {
// MB
$bytes = $bytes * 1048576;
}
$datalimit = (string)$bytes;
}
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$userData = [
'name' => $name,
'password' => $password_user,
'profile' => $profile,
'comment' => $comment
];
if(!empty($timelimit)) $userData['limit-uptime'] = $timelimit;
if(!empty($datalimit)) $userData['limit-bytes-total'] = $datalimit;
$API->comm("/ip/hotspot/user/add", $userData);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.user_added', 'toasts.user_added_desc', ['name' => $name], true);
header("Location: /" . $session . "/hotspot/users");
exit;
}
public function delete() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$rawId = $_POST['id'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
// Handle Multiple IDs (comma separated)
$ids = explode(',', $rawId);
foreach ($ids as $id) {
$id = trim($id);
if (!empty($id)) {
// 1. Get Username first (to delete scheduler)
$user = $API->comm("/ip/hotspot/user/print", [
"?.id" => $id
]);
if (!empty($user) && isset($user[0]['name'])) {
$username = $user[0]['name'];
// 2. Remove User
$API->comm("/ip/hotspot/user/remove", [".id" => $id]);
// 3. Remove Scheduler (Ghost Cleanup)
// Check if scheduler exists with same name as user
$schedules = $API->comm("/system/scheduler/print", [
"?name" => $username
]);
if(!empty($schedules)) {
// Loop just in case multiple matches (unlikely if unique name)
foreach($schedules as $sch) {
$API->comm("/system/scheduler/remove", [
".id" => $sch['.id']
]);
}
}
}
}
}
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.user_deleted', 'toasts.user_deleted_desc', [], true);
header("Location: /" . $session . "/hotspot/users");
exit;
}
public function edit($session, $id) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
$user = null;
$profiles = [];
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
// Fetch specific user
$userRequest = $API->comm("/ip/hotspot/user/print", [
"?.id" => $id
]);
if (!empty($userRequest)) {
$user = $userRequest[0];
// Parse Time Limit (limit-uptime) e.g. 1d04:00:00 or 30d
// Mikrotik uptime format varies. Safe parse:
// Regex for Xd, Xh, Xm? NO, Mikrotik returns "4w2d" or "10:00:00" (h:m:s)
// Or simple seconds if raw? Print usually returns formatted.
// Let's defer to a helper or simple parsing.
// Actually standard format: 1d 04:00:00 or 1h30m.
// Let's try simple regex extraction.
$t_d = ''; $t_h = ''; $t_m = '';
$uptime = $user['limit-uptime'] ?? '';
if ($uptime) {
if (preg_match('/(\d+)d/', $uptime, $m)) $t_d = $m[1];
if (preg_match('/(\d+)h/', $uptime, $m)) $t_h = $m[1];
if (preg_match('/(\d+)m/', $uptime, $m)) $t_m = $m[1];
// Handle H:M:S format (e.g. 04:00:00) if no 'h'/'m' chars?
// Mikrotik CLI `print` implies "1d04:00:00". API might return "1d04:00:00".
// If so, 04 is hours.
// Simple parse if regex failed?
// Let's assume standard XdXhXm usage for now based on Add form.
}
$user['time_d'] = $t_d;
$user['time_h'] = $t_h;
$user['time_m'] = $t_m;
// Parse Data Limit (limit-bytes-total)
$bytes = $user['limit-bytes-total'] ?? 0;
$d_val = ''; $d_unit = 'MB';
if ($bytes > 0) {
if ($bytes >= 1073741824) {
$d_val = round($bytes / 1073741824, 2);
$d_unit = 'GB';
} else {
$d_val = round($bytes / 1048576, 2);
$d_unit = 'MB';
}
}
$user['data_val'] = $d_val;
$user['data_unit'] = $d_unit;
}
// Fetch Profiles
$profiles = $API->comm("/ip/hotspot/user/profile/print");
$API->disconnect();
}
if (!$user) {
header("Location: /" . $session . "/hotspot/users");
exit;
}
$data = [
'session' => $session,
'user' => $user,
'profiles' => $profiles
];
return $this->view('hotspot/users/edit', $data);
}
public function update() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
$name = $_POST['name'] ?? '';
$password_user = $_POST['password'] ?? '';
$profile = $_POST['profile'] ?? '';
$comment = $_POST['comment'] ?? '';
$server = $_POST['server'] ?? 'all';
// Time Limit Logic (d, h, m)
$timelimit_d = $_POST['timelimit_d'] ?? '';
$timelimit_h = $_POST['timelimit_h'] ?? '';
$timelimit_m = $_POST['timelimit_m'] ?? '';
$timelimit = '';
if ($timelimit_d != '') $timelimit .= $timelimit_d . 'd';
if ($timelimit_h != '') $timelimit .= $timelimit_h . 'h';
if ($timelimit_m != '') $timelimit .= $timelimit_m . 'm';
// Data Limit Logic (Value, Unit)
$datalimit_val = $_POST['datalimit_val'] ?? '';
$datalimit_unit = $_POST['datalimit_unit'] ?? 'MB';
$datalimit = '0';
if (!empty($datalimit_val) && is_numeric($datalimit_val)) {
$bytes = (float)$datalimit_val; // float to handle decimals before calc
if ($datalimit_unit === 'GB') {
$bytes = $bytes * 1073741824;
} else {
// MB
$bytes = $bytes * 1048576;
}
$datalimit = (string)round($bytes);
}
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$userData = [
'.id' => $id,
'name' => $name,
'password' => $password_user,
'profile' => $profile,
'comment' => $comment,
'server' => $server
];
if(!empty($timelimit)) $userData['limit-uptime'] = $timelimit;
else $userData['limit-uptime'] = '0s'; // Reset if empty
// Always set if calculated, 0 resets it.
$userData['limit-bytes-total'] = $datalimit;
$API->comm("/ip/hotspot/user/set", $userData);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.user_updated', 'toasts.user_updated_desc', ['name' => $name], true);
header("Location: /" . $session . "/hotspot/users");
exit;
}
public function active($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
header("Location: /");
exit;
}
$items = [];
$error = null;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$items = $API->comm("/ip/hotspot/active/print");
$API->disconnect();
} else {
$error = "Connection Failed to " . $creds['ip'];
}
$data = [
'session' => $session,
'items' => $items,
'error' => $error
];
return $this->view('status/active', $data);
}
public function removeActive() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$API->comm("/ip/hotspot/active/remove", [".id" => $id]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.session_removed', 'toasts.session_removed_desc', [], true);
header("Location: /" . $session . "/hotspot/active");
exit;
}
public function hosts($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
header("Location: /");
exit;
}
$items = [];
$error = null;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$items = $API->comm("/ip/hotspot/host/print");
$API->disconnect();
} else {
$error = "Connection Failed to " . $creds['ip'];
}
$data = [
'session' => $session,
'items' => $items,
'error' => $error
];
return $this->view('status/hosts', $data);
}
public function bindings($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
header("Location: /");
exit;
}
$items = [];
$error = null;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$items = $API->comm("/ip/hotspot/ip-binding/print");
$API->disconnect();
} else {
$error = "Connection Failed to " . $creds['ip'];
}
$data = [
'session' => $session,
'items' => $items,
'error' => $error
];
return $this->view('security/bindings', $data);
}
public function storeBinding() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$mac = $_POST['mac'] ?? '';
$address = $_POST['address'] ?? '';
$toAddress = $_POST['to_address'] ?? '';
$server = $_POST['server'] ?? 'all';
$type = $_POST['type'] ?? 'regular';
$comment = $_POST['comment'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$data = [
'mac-address' => $mac,
'type' => $type,
'comment' => $comment,
'server' => $server
];
if(!empty($address)) $data['address'] = $address;
if(!empty($toAddress)) $data['to-address'] = $toAddress;
$API->comm("/ip/hotspot/ip-binding/add", $data);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.binding_added', 'toasts.binding_added_desc', [], true);
header("Location: /" . $session . "/hotspot/bindings");
exit;
}
public function removeBinding() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$API->comm("/ip/hotspot/ip-binding/remove", [".id" => $id]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.binding_removed', 'toasts.binding_removed_desc', [], true);
header("Location: /" . $session . "/hotspot/bindings");
exit;
}
public function walledGarden($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
header("Location: /");
exit;
}
$items = [];
$error = null;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$items = $API->comm("/ip/hotspot/walled-garden/ip/print");
// Standard walled garden print usually involves /ip/hotspot/walled-garden/ip/print for IP based or just /ip/hotspot/walled-garden/print
// Let's use /ip/hotspot/walled-garden/ip/print as general walled garden often implies IP based rules in modern RouterOS or just walled-garden
// Actually, usually there is /ip/hotspot/walled-garden (Dst Host, etc) and /ip/hotspot/walled-garden/ip (Dst Address, etc)
// Mikhmon v3 usually merges them or uses one.
// Let's check typical Mikhmon usage. Usually "Walled Garden" uses `/ip/hotspot/walled-garden/print` (which captures domains) and IP List uses `/ip/hotspot/walled-garden/ip/print`.
// My view lists Dst Host / IP.
// Let's fetch BOTH and merge, or just one.
// For now, let's target `/ip/hotspot/walled-garden/ip/print` as it allows protocol, port, dst-address, dst-host (in newer ROS?).
// Wait, `/ip/hotspot/walled-garden/print` allows `dst-host`.
// `/ip/hotspot/walled-garden/ip/print` allows `dst-address`.
// I'll stick to `/ip/hotspot/walled-garden/ip/print` for now as it seems more robust for IP rules, but domains need `walled-garden/print`.
// Actually, let's look at `walled_garden.php`. It handles `dst-host` or `dst-address`.
// I will use `/ip/hotspot/walled-garden/ip/print` which is "Walled Garden IP List". This is usually what people mean by "Walled Garden" for banking apps etc (IP ranges or strict definitions).
// BUT domain bypasses are in `/ip/hotspot/walled-garden/print`.
// Let's try to fetch `/ip/hotspot/walled-garden/ip/print` first.
$items = $API->comm("/ip/hotspot/walled-garden/ip/print");
$API->disconnect();
} else {
$error = "Connection Failed to " . $creds['ip'];
}
$data = [
'session' => $session,
'items' => $items,
'error' => $error
];
return $this->view('security/walled_garden', $data);
}
public function storeWalledGarden() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$dstHost = $_POST['dst_host'] ?? '';
$dstAddress = $_POST['dst_address'] ?? '';
$protocol = $_POST['protocol'] ?? '';
$dstPort = $_POST['dst_port'] ?? '';
$action = $_POST['action'] ?? 'allow';
$server = $_POST['server'] ?? 'all';
$comment = $_POST['comment'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$data = [
'action' => $action,
'server' => $server,
'comment' => $comment
];
// If dst-host is present, we might need to use /ip/hotspot/walled-garden/add instead of /ip/.../ip/add?
// RouterOS distinguishes them. active.php shows I used `walled-garden/ip/print`.
// If user enters dst-host, it usually goes to `walled-garden`. If dst-address, `walled-garden/ip`.
// This is complex. Let's assume we are adding to `walled-garden/ip` for now which supports protocol/port/dst-address but NOT dst-host typically (older ROS).
// Actually, newer ROS might merge.
// Let's assume standard behavior:
// If dst-host is provided, add to `/ip/hotspot/walled-garden/add`.
// If dst-address is provided, add to `/ip/hotspot/walled-garden/ip/add`.
// My View asks for BOTH?
// Let's simplification: Check if dst_host is set.
$path = "/ip/hotspot/walled-garden/ip/add";
if (!empty($dstHost)) {
$path = "/ip/hotspot/walled-garden/add";
$data['dst-host'] = $dstHost;
} else {
if(!empty($dstAddress)) $data['dst-address'] = $dstAddress;
}
// Protocol and Port logic
// Note: `walled-garden` (host) takes protocol/port too? Yes.
if(!empty($protocol)) {
// extract protocol name if format is "(6) tcp"
if(preg_match('/\)\s*(\w+)/', $protocol, $m)) $protocol = $m[1];
$data['protocol'] = $protocol;
}
if(!empty($dstPort)) $data['dst-port'] = $dstPort;
$API->comm($path, $data);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.rule_added', 'toasts.rule_added_desc', [], true);
header("Location: /" . $session . "/hotspot/walled-garden");
exit;
}
public function removeWalledGarden() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$API->comm("/ip/hotspot/walled-garden/ip/remove", [".id" => $id]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.rule_removed', 'toasts.rule_removed_desc', [], true);
header("Location: /" . $session . "/hotspot/walled-garden");
exit;
}
// Print Single User
public function printUser($session, $id) {
return $this->printBatch($session, $id);
}
// Print Batch Users (Comma separated IDs)
public function printBatchActions($session) {
$ids = $_GET['ids'] ?? '';
return $this->printBatch($session, $ids);
}
// Cookies
public function cookies($session) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return header('Location: /');
$cookies = [];
$API = new RouterOSAPI();
$API->attempts = 1;
$API->timeout = 3;
if ($API->connect($creds['ip_address'], $creds['username'], $creds['password'])) {
$cookies = $API->comm("/ip/hotspot/cookie/print");
$API->disconnect();
}
return $this->view('hotspot/cookies', [
'session' => $session,
'cookies' => $cookies ?? []
]);
}
public function removeCookie() {
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
if ($API->connect($creds['ip_address'], $creds['username'], $creds['password'])) {
$API->comm("/ip/hotspot/cookie/remove", [".id" => $id]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.cookie_removed', 'toasts.cookie_removed_desc', [], true);
header("Location: /$session/hotspot/cookies");
}
private function printBatch($session, $rawIds) {
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) die("Session error");
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
// Handle ID List
// IDs can be "id1,id2,id3"
// Also Mikrotik IDs start with *, we need to ensure they are handled.
// If passed via URL, `*` might be encoded.
$idList = explode(',', urldecode($rawIds));
$validUsers = [];
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
// Optimized: Fetch ALL users and filter PHP side?
// Or fetch loop? Mikrotik API `/print` with `?.id` only supports single match usually or we need filtered print.
// Usually `print` returning all is faster for < 1000 users than 100 calls.
// But if we have 5000 users, we shouldn't fetch all.
// Mikrotik API doesn't support `WHERE id IN (...)`.
// So for batch, we might have to loop calls OR fetch all and filter if list is huge.
// Let's loop for now as batch print is usually < 50 items.
foreach ($idList as $id) {
// Ensure ID has * if missing (unlikely if coming from app logic)
$req = $API->comm("/ip/hotspot/user/print", [
"?.id" => $id
]);
if (!empty($req)) {
$u = $req[0];
$validUsers[] = [
'username' => $u['name'],
'password' => $u['password'] ?? '',
'price' => $u['price'] ?? '',
'validity' => $u['limit-uptime'] ?? '',
'timelimit' => \App\Helpers\HotspotHelper::formatValidity($u['limit-uptime'] ?? ''),
'datalimit' => \App\Helpers\HotspotHelper::formatBytes($u['limit-bytes-total'] ?? 0),
'profile' => $u['profile'] ?? 'default',
'comment' => $u['comment'] ?? '',
'hotspotname' => $creds['hotspot_name'],
'dns_name' => $creds['dns_name'],
'login_url' => (preg_match("~^(?:f|ht)tps?://~i", $creds['dns_name']) ? $creds['dns_name'] : "http://" . $creds['dns_name']) . "/login"
];
}
}
$API->disconnect();
}
if (empty($validUsers)) die("No users found");
// --- Template Handling ---
$tplModel = new VoucherTemplateModel();
$templates = $tplModel->getAll(); // Need session? Model usually handles simple select, maybe filter by session later if needed? Schema says global?
// Verification: Schema in implementation plan says id, name, content... doesn't mention session. Assuming global.
$currentTemplate = $_GET['template'] ?? 'default';
$templateContent = '';
$viewName = 'print/default';
if ($currentTemplate !== 'default') {
$tpl = $tplModel->getById($currentTemplate);
if ($tpl) {
$templateContent = $tpl['content'];
$viewName = 'print/custom';
} else {
// Fallback if ID invalid
$currentTemplate = 'default';
}
}
// --- Logo Handling ---
$logoModel = new \App\Models\Logo();
$logos = $logoModel->getAll();
$logoMap = [];
foreach ($logos as $l) {
$logoMap[$l['id']] = $l['path'];
}
$data = [
'users' => $validUsers,
'templates' => $templates,
'currentTemplate' => $currentTemplate,
'templateContent' => $templateContent,
'session' => $session,
'logoMap' => $logoMap
];
return $this->view($viewName, $data);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Database;
use App\Core\Migrations;
use App\Config\SiteConfig;
class InstallController extends Controller {
public function index() {
// Check if already installed
if ($this->isInstalled()) {
header('Location: /login');
exit;
}
return $this->view('install');
}
public function process() {
if ($this->isInstalled()) {
header('Location: /login');
exit;
}
$username = $_POST['username'] ?? 'admin';
$password = $_POST['password'] ?? 'admin';
try {
// 1. Run Migrations
Migrations::up();
// 2. Generate Key if default
if (SiteConfig::getSecretKey() === 'mikhmonv3remake_secret_key_32bytes') {
$this->generateKey();
}
// 3. Create Admin
$db = Database::getInstance();
$hash = password_hash($password, PASSWORD_DEFAULT);
// Check if user exists (edge case where key was default but user existed)
$check = $db->query("SELECT id FROM users WHERE username = ?", [$username])->fetch();
if (!$check) {
$db->query("INSERT INTO users (username, password, created_at) VALUES (?, ?, ?)", [
$username, $hash, date('Y-m-d H:i:s')
]);
} else {
$db->query("UPDATE users SET password = ? WHERE username = ?", [$hash, $username]);
}
// Success
\App\Helpers\FlashHelper::set('success', 'Installation Complete', 'System has been successfully installed. Please login.');
header('Location: /login');
exit;
} catch (\Exception $e) {
\App\Helpers\FlashHelper::set('error', 'Installation Failed', $e->getMessage());
header('Location: /install');
exit;
}
}
private function isInstalled() {
// Check if .env exists and APP_KEY is set to something other than the default/example
$envPath = ROOT . '/.env';
if (!file_exists($envPath)) {
// Check if SiteConfig has a manual override (legacy)
return SiteConfig::getSecretKey() !== 'mikhmonv3remake_secret_key_32bytes';
}
$key = getenv('APP_KEY');
$keyChanged = ($key && $key !== 'mikhmonv3remake_secret_key_32bytes');
try {
$db = Database::getInstance();
$count = $db->query("SELECT count(*) as c FROM users")->fetchColumn();
$hasUser = ($count > 0);
} catch (\Exception $e) {
$hasUser = false;
}
return $keyChanged && $hasUser;
}
private function generateKey() {
$key = bin2hex(random_bytes(16));
$envPath = ROOT . '/.env';
$examplePath = ROOT . '/.env.example';
if (!file_exists($envPath)) {
if (file_exists($examplePath)) {
copy($examplePath, $envPath);
} else {
return; // Cannot generate without source
}
}
$content = file_get_contents($envPath);
if (strpos($content, 'APP_KEY=') !== false) {
$newContent = preg_replace(
"/APP_KEY=.*/",
"APP_KEY=$key",
$content
);
} else {
$newContent = $content . "\nAPP_KEY=$key";
}
file_put_contents($envPath, $newContent);
// Refresh env in current session so next steps use it
putenv("APP_KEY=$key");
$_ENV['APP_KEY'] = $key;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
class LogController extends Controller
{
public function index($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) return header('Location: /');
$logs = [];
$API = new RouterOSAPI();
$API->attempts = 1;
$API->timeout = 3;
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
// Fetch Hotspot Logs
// /log/print where topics~hotspot
// In API we can't always filter effectively by topic in all versions,
// but we can try ?topics=hotspot,info or similar.
// Safe bet: fetch last 100 logs and filter PHP side or use API filter if possible.
// Using a limit to avoid timeout.
// Getting generic logs for now, filtered by topic 'hotspot' if possible.
// RouterOS API query for array search: ?topics=hotspot
$logs = $API->comm("/log/print", [
"?topics" => "hotspot,info,debug", // Try detailed match
]);
// Fallback if strict match fails, just get recent logs
if (empty($logs) || isset($logs['!trap'])) {
$logs = $API->comm("/log/print", []); // Get all (capped usually by buffer)
}
// Reverse to show newest first
if (is_array($logs)) {
$logs = array_reverse($logs);
}
$API->disconnect();
}
return $this->view('reports/user_log', [
'session' => $session,
'logs' => $logs
]);
}
}

View File

@@ -0,0 +1,348 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
class ProfileController extends Controller
{
public function index($session)
{
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
$this->redirect('/');
return;
}
$API = new RouterOSAPI();
// Use default port 8728 if not specified
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
$profiles = $API->comm('/ip/hotspot/user/profile/print');
$API->disconnect();
// Process profiles to add metadata from on-login script
foreach ($profiles as &$profile) {
$meta = \App\Helpers\HotspotHelper::parseProfileMetadata($profile['on-login'] ?? '');
$profile['meta'] = $meta;
$profile['meta']['expired_mode_formatted'] = \App\Helpers\HotspotHelper::formatExpiredMode($meta['expired_mode'] ?? '');
}
$this->view('hotspot/profiles/index', [
'session' => $session,
'profiles' => $profiles,
'title' => 'User Profiles'
]);
} else {
$this->view('hotspot/profiles/index', [
'session' => $session,
'profiles' => [],
'error' => 'Connection Failed to ' . $creds['ip'],
'title' => 'User Profiles'
]);
}
}
public function add($session)
{
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
$this->redirect('/');
return;
}
$API = new RouterOSAPI();
$pools = [];
$queues = [];
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
$pools = $API->comm('/ip/pool/print');
// Fetch Queues (Simple & Tree)
$simple = $API->comm('/queue/simple/print');
$tree = $API->comm('/queue/tree/print');
// Extract just names for dropdown
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();
}
$this->view('hotspot/profiles/add', [
'session' => $session,
'pools' => $pools,
'queues' => $queues
]);
}
public function store()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/');
return;
}
$session = $_POST['session'] ?? '';
$name = $_POST['name'] ?? '';
$sharedUsers = $_POST['shared-users'] ?? '1';
$rateLimit = $_POST['rate-limit'] ?? '';
$addressPool = $_POST['address-pool'] ?? 'none';
$parentQueue = $_POST['parent-queue'] ?? 'none';
// Metadata fields
$expiredMode = $_POST['expired_mode'] ?? 'none';
// Validity Logic
$val_d = $_POST['validity_d'] ?? '';
$val_h = $_POST['validity_h'] ?? '';
$val_m = $_POST['validity_m'] ?? '';
$validity = '';
if($val_d) $validity .= $val_d . 'd';
if($val_h) $validity .= $val_h . 'h';
if($val_m) $validity .= $val_m . 'm';
$price = $_POST['price'] ?? '';
$sellingPrice = $_POST['selling_price'] ?? '';
$lockUser = $_POST['lock_user'] ?? 'Disable';
// Construct on-login script
// Construct on-login script
$metaScript = sprintf(
':put (",%s,%s,%s,%s,,%s,")',
$expiredMode,
$price,
$validity,
$sellingPrice,
$lockUser
);
// Logic Script (The "Enforcer") - Enforces Calendar Validity
// Automates adding a scheduler to Disable user after "Validity" time passes from first login.
// Update: Added Self-Cleaning logic (:do {} on-error={}) to ensure scheduler deletes itself
// even if user was manually deleted from Winbox.
$logicScript = "";
if (!empty($validity)) {
$logicScript = ' :local v "'.$validity.'"; :local u $user; :local c [/ip hotspot user get [find name=$u] comment]; :if ([:find $c "exp"] = -1) do={ /sys sch add name=$u interval=$v on-event=":do { /ip hotspot user set [find name=$u] disabled=yes } on-error={}; /sys sch remove [find name=$u]"; /ip hotspot user set [find name=$u] comment=("exp: " . $v . " " . $c); }';
}
$onLogin = $metaScript . $logicScript;
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
$profileData = [
'name' => $name,
'shared-users' => $sharedUsers,
'on-login' => $onLogin,
'address-pool' => $addressPool,
'parent-queue' => $parentQueue
];
if ($parentQueue === 'none') {
unset($profileData['parent-queue']); // Or handle appropriately if Mikrotik accepts 'none' or unset
}
if (!empty($rateLimit)) {
$profileData['rate-limit'] = $rateLimit;
}
$API->comm("/ip/hotspot/user/profile/add", $profileData);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.profile_created', 'toasts.profile_created_desc', ['name' => $name], true);
$this->redirect('/' . $session . '/hotspot/profiles');
}
public function delete()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/');
return;
}
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
if (empty($session) || empty($id)) {
$this->redirect('/');
return;
}
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
$API->comm("/ip/hotspot/user/profile/remove", [
".id" => $id,
]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.profile_deleted', 'toasts.profile_deleted_desc', [], true);
$this->redirect('/' . $session . '/hotspot/profiles');
}
public function edit($session, $id)
{
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
$this->redirect('/');
return;
}
$API = new RouterOSAPI();
$profile = null;
$pools = [];
$queues = [];
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
$pools = $API->comm('/ip/pool/print');
// Fetch Queues (Simple & Tree)
$simple = $API->comm('/queue/simple/print');
$tree = $API->comm('/queue/tree/print');
foreach ($simple as $q) {
if(isset($q['name'])) $queues[] = $q['name'];
}
foreach ($tree as $q) {
if(isset($q['name'])) $queues[] = $q['name'];
}
sort($queues);
$profiles = $API->comm('/ip/hotspot/user/profile/print', [
"?.id" => $id
]);
if (!empty($profiles)) {
$profile = $profiles[0];
// Parse metadata
$meta = \App\Helpers\HotspotHelper::parseProfileMetadata($profile['on-login'] ?? '');
$profile['meta'] = $meta;
// Parse Validity
$val_d = '';
$val_h = '';
$val_m = '';
if (!empty($meta['validity'])) {
if (preg_match('/(\d+)d/', $meta['validity'], $m)) $val_d = $m[1];
if (preg_match('/(\d+)h/', $meta['validity'], $m)) $val_h = $m[1];
if (preg_match('/(\d+)m/', $meta['validity'], $m)) $val_m = $m[1];
}
$profile['val_d'] = $val_d;
$profile['val_h'] = $val_h;
$profile['val_m'] = $val_m;
}
$API->disconnect();
}
if (!$profile) {
$this->redirect('/' . $session . '/hotspot/profiles');
return;
}
$this->view('hotspot/profiles/edit', [
'session' => $session,
'profile' => $profile,
'pools' => $pools,
'queues' => $queues
]);
}
public function update()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/');
return;
}
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
$name = $_POST['name'] ?? '';
$sharedUsers = $_POST['shared-users'] ?? '1';
$rateLimit = $_POST['rate-limit'] ?? '';
$addressPool = $_POST['address-pool'] ?? 'none';
$parentQueue = $_POST['parent-queue'] ?? 'none';
// Metadata fields
$expiredMode = $_POST['expired_mode'] ?? 'none';
// Validity Logic
$val_d = $_POST['validity_d'] ?? '';
$val_h = $_POST['validity_h'] ?? '';
$val_m = $_POST['validity_m'] ?? '';
$validity = '';
if($val_d) $validity .= $val_d . 'd';
if($val_h) $validity .= $val_h . 'h';
if($val_m) $validity .= $val_m . 'm';
$price = $_POST['price'] ?? '';
$sellingPrice = $_POST['selling_price'] ?? '';
$lockUser = $_POST['lock_user'] ?? 'Disable';
$metaScript = sprintf(
':put (",%s,%s,%s,%s,,%s,")',
$expiredMode,
$price,
$validity,
$sellingPrice,
$lockUser
);
// Logic Script (The "Enforcer")
$logicScript = "";
if (!empty($validity)) {
$logicScript = ' :local v "'.$validity.'"; :local u $user; :local c [/ip hotspot user get [find name=$u] comment]; :if ([:find $c "exp"] = -1) do={ /sys sch add name=$u interval=$v on-event=":do { /ip hotspot user set [find name=$u] disabled=yes } on-error={}; /sys sch remove [find name=$u]"; /ip hotspot user set [find name=$u] comment=("exp: " . $v . " " . $c); }';
}
$onLogin = $metaScript . $logicScript;
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) return;
$API = new RouterOSAPI();
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
$profileData = [
'.id' => $id,
'name' => $name,
'shared-users' => $sharedUsers,
'on-login' => $onLogin,
'address-pool' => $addressPool,
'parent-queue' => $parentQueue
];
if ($parentQueue === 'none') {
unset($profileData['parent-queue']);
}
$profileData['rate-limit'] = $rateLimit;
$API->comm("/ip/hotspot/user/profile/set", $profileData);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.profile_updated', 'toasts.profile_updated_desc', ['name' => $name], true);
$this->redirect('/' . $session . '/hotspot/profiles');
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Config\SiteConfig;
use App\Libraries\RouterOSAPI;
use App\Helpers\EncryptionHelper;
use App\Helpers\FormatHelper;
class PublicStatusController extends Controller {
// View: Show Search Page
public function index($session) {
// Just verify session existence to display Hotspot Name
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
// If session invalid, maybe show 404 or generic error
echo "Session not found.";
return;
}
$data = [
'session' => $session,
'hotspot_name' => $creds['hotspot_name'] ?? 'Hotspot',
'footer_text' => SiteConfig::getFooter()
];
return $this->view('public/status', $data);
}
// API: Check Status
public function check($codeUrl = null) {
header('Content-Type: application/json');
// Allow POST and GET
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && $_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['error' => 'Method Not Allowed']);
return;
}
$input = json_decode(file_get_contents('php://input'), true) ?? [];
// Session: Try Body -> Try Header
$session = $input['session'] ?? '';
if (empty($session)) {
$headers = getallheaders();
// Handle case-insensitivity of headers
$session = $headers['X-Mivo-Session'] ?? ($headers['x-mivo-session'] ?? '');
}
// Code: Can be in URL or Body
$code = $codeUrl ?? ($input['code'] ?? '');
if (empty($session) || empty($code)) {
http_response_code(400);
echo json_encode(['error' => 'Session and Voucher Code are required']);
return;
}
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) {
http_response_code(404);
echo json_encode(['error' => 'Session not found']);
return;
}
$password = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password = RouterOSAPI::decrypt($password);
}
$api = new RouterOSAPI();
if (!$api->connect($creds['ip'], $creds['user'], $password)) {
http_response_code(500);
echo json_encode(['error' => 'Router Connection Failed']);
return;
}
// Logic Refactor: Pivot to User Table as primary source for Voucher Details
// 1. Check User in Database
$user = $api->comm("/ip/hotspot/user/print", [
"?name" => $code
]);
if (!empty($user)) {
$u = $user[0];
// DEBUG: Log the user data to see raw values
error_log("Status Debug: " . json_encode($u));
// --- SECURITY CHECK: Hide Unused Vouchers ---
$uptimeRaw = $u['uptime'] ?? '0s';
$bytesIn = intval($u['bytes-in'] ?? 0);
$bytesOut = intval($u['bytes-out'] ?? 0);
if (($uptimeRaw === '0s' || empty($uptimeRaw)) && ($bytesIn + $bytesOut) === 0) {
$api->disconnect();
echo json_encode(['success' => false, 'message' => 'Voucher Not Found']);
return;
}
// --- SECURITY CHECK: Hide Unlimited Members ---
$limitBytes = isset($u['limit-bytes-total']) ? intval($u['limit-bytes-total']) : 0;
$limitUptime = $u['limit-uptime'] ?? '0s';
if ($limitBytes === 0 && ($limitUptime === '0s' || empty($limitUptime))) {
// Option: Allow checking them but show minimalistic info, or hide.
// Sticking to original logic: Hide them.
$api->disconnect();
echo json_encode(['success' => false, 'message' => 'Voucher Not Found']);
return;
}
// --- CALCULATIONS ---
$dataUsed = $bytesIn + $bytesOut;
$dataLeft = 'Unlimited';
if ($limitBytes > 0) {
$remaining = max(0, $limitBytes - $dataUsed);
$dataLeft = ($remaining === 0) ? '0 B' : FormatHelper::formatBytes($remaining);
}
// Validity Logic
$validityRaw = $u['limit-uptime'] ?? '0s';
$validityDisplay = ($validityRaw === '0s') ? 'Unlimited' : FormatHelper::elapsedTime($validityRaw);
$expiration = '-';
$comment = strtolower($u['comment'] ?? '');
if (preg_match('/exp\W+([a-z]{3}\/\d{2}\/\d{4}\s\d{2}:\d{2}:\d{2})/', $comment, $matches)) {
$expiration = $matches[1];
} elseif ($validityRaw !== '0s') {
$totalSeconds = FormatHelper::parseDuration($validityRaw);
$usedSeconds = FormatHelper::parseDuration($uptimeRaw);
$remainingSeconds = max(0, $totalSeconds - $usedSeconds);
if ($remainingSeconds > 0) {
$expiration = date('d M Y H:i', time() + $remainingSeconds);
} else {
$expiration = 'Expired';
}
}
// BASE STATUS
$status = 'offline';
$statusLabel = 'Valid / Offline';
$isDisabled = ($u['disabled'] ?? 'false') === 'true';
// Calculate Time Left
$timeLeft = 'Unlimited';
if ($expiration !== '-' && $expiration !== 'Expired') {
$expTime = strtotime($expiration);
if ($expTime) {
$rem = max(0, $expTime - time());
$timeLeft = ($rem === 0) ? 'Expired' : FormatHelper::formatSeconds($rem);
}
} elseif ($validityRaw !== '0s') {
$totalSeconds = FormatHelper::parseDuration($validityRaw);
$usedSeconds = FormatHelper::parseDuration($uptimeRaw);
$rem = max(0, $totalSeconds - $usedSeconds);
$timeLeft = ($rem === 0) ? 'Expired' : FormatHelper::formatSeconds($rem);
}
if (strpos($comment, 'exp') !== false || ($expiration === 'Expired')) {
$status = 'expired';
$statusLabel = 'Expired';
} elseif ($limitBytes > 0 && $dataUsed >= $limitBytes) {
$status = 'limited';
$statusLabel = 'Quota Exceeded';
} elseif ($isDisabled) {
$status = 'locked';
$statusLabel = 'Locked / Disabled';
}
// 2. CHECK ACTIVE OVERRIDE
// If user is conceptually valid (or even if limited?), check if they are currently active
// Because they might be active BUT expiring soon, or active BUT over quota (if server hasn't kicked them yet)
$active = $api->comm("/ip/hotspot/active/print", [
"?user" => $code
]);
if (!empty($active)) {
$status = 'active';
$statusLabel = 'Active (Online)';
}
$data = [
'status' => $status,
'status_label' => $statusLabel,
'username' => $u['name'] ?? 'Unknown',
'profile' => $u['profile'] ?? 'default',
'uptime_used' => FormatHelper::elapsedTime($uptimeRaw),
'validity' => $validityDisplay,
'data_used' => FormatHelper::formatBytes($dataUsed),
'data_left' => $dataLeft,
'expiration' => $expiration,
'time_left' => $timeLeft,
'comment' => $u['comment'] ?? '',
];
echo json_encode(['success' => true, 'data' => $data]);
$api->disconnect();
return;
}
// 3. Fallback: Check Active Only (Trial Users or IP Bindings not in User Table)
$active = $api->comm("/ip/hotspot/active/print", [
"?user" => $code
]);
if (!empty($active)) {
$u = $active[0];
$data = [
'status' => 'active',
'status_label' => 'Active (Online)',
'username' => $u['user'] ?? 'Unknown',
'profile' => '-', // Active usually doesn't have profile name directly unless queried
'uptime_used' => FormatHelper::elapsedTime($u['uptime'] ?? '0s'),
'validity' => '-',
'data_used' => FormatHelper::formatBytes(intval($u['bytes-in'] ?? 0) + intval($u['bytes-out'] ?? 0)),
'data_left' => 'Unknown',
'time_left' => isset($u['session-time-left']) ? FormatHelper::elapsedTime($u['session-time-left']) : '-',
'expiration' => '-',
'comment' => ''
];
echo json_encode(['success' => true, 'data' => $data]);
$api->disconnect();
return;
}
$api->disconnect();
echo json_encode(['success' => false, 'message' => 'Voucher Not Found']);
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Models\QuickPrintModel;
use App\Models\VoucherTemplateModel;
use App\Libraries\RouterOSAPI;
use App\Core\Middleware;
use App\Helpers\EncryptionHelper;
class QuickPrintController extends Controller {
public function __construct() {
Middleware::auth();
}
// Dashboard: List Cards
public function index($session) {
$qpModel = new QuickPrintModel();
$packages = $qpModel->getAllBySession($session);
$data = [
'session' => $session,
'packages' => $packages
];
// Note: View will be 'quick_print/index'
return $this->view('quick_print/index', $data);
}
// List/Manage Packages (CRUD)
public function manage($session) {
$qpModel = new QuickPrintModel();
$packages = $qpModel->getAllBySession($session);
// Need profiles for the Add/Edit Modal
$configModel = new Config();
$creds = $configModel->getSession($session);
$profiles = [];
if ($creds) {
$API = new RouterOSAPI();
$password = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password = RouterOSAPI::decrypt($password);
}
if ($API->connect($creds['ip'], $creds['user'], $password)) {
$profiles = $API->comm("/ip/hotspot/user/profile/print");
$API->disconnect();
}
}
$data = [
'session' => $session,
'packages' => $packages,
'profiles' => $profiles
];
return $this->view('quick_print/list', $data);
}
// CRUD: Store
public function store() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$session = $_POST['session'] ?? '';
$data = [
'session_name' => $session,
'name' => $_POST['name'] ?? 'Package',
'server' => $_POST['server'] ?? 'all',
'profile' => $_POST['profile'] ?? 'default',
'prefix' => $_POST['prefix'] ?? '',
'char_length' => $_POST['char_length'] ?? 4,
'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->add($data);
\App\Helpers\FlashHelper::set('success', 'toasts.package_saved', 'toasts.package_saved_desc', [], true);
header("Location: /" . $session . "/quick-print/manage");
exit;
}
// CRUD: Delete
public function delete() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
$qpModel = new QuickPrintModel();
$qpModel->delete($id);
\App\Helpers\FlashHelper::set('success', 'toasts.package_deleted', 'toasts.package_deleted_desc', [], true);
header("Location: /" . $session . "/quick-print/manage");
exit;
}
// ACTION: Generate User & Print
public function printPacket($session, $id) {
// 1. Get Package Details
$qpModel = new QuickPrintModel();
$package = $qpModel->getById($id);
if (!$package) {
die("Package not found");
}
// 2. Generate Credentials
$prefix = $package['prefix'];
$length = $package['char_length'];
$charSet = '1234567890abcdefghijklmnopqrstuvwxyz'; // Simple lowercase + num
$rand = substr(str_shuffle($charSet), 0, $length);
$username = $prefix . $rand;
$password = $username; // Default: user=pass (User Mode) - Can be improved later
// 3. Connect to Mikrotik & Add User
$configModel = new Config();
$creds = $configModel->getSession($session);
if (!$creds) die("Session error");
$API = new RouterOSAPI();
$password_router = $creds['password'];
if (isset($creds['source']) && $creds['source'] === 'legacy') {
$password_router = RouterOSAPI::decrypt($password_router);
}
if ($API->connect($creds['ip'], $creds['user'], $password_router)) {
$userData = [
'name' => $username,
'password' => $password,
'profile' => $package['profile'],
'comment' => $package['comment'] . " [QP]" // Mark as QuickPrint
];
// Limits
if(!empty($package['time_limit'])) $userData['limit-uptime'] = $package['time_limit'];
if(!empty($package['data_limit'])) {
// Check if M or G
// Simple logic for now, assuming raw if number, or passing string if Mikrotik accepts it (usually requires bytes)
// Let's assume user inputs "100M" or "1G" which usually needs parsing.
// For now, let's assume input is NUMBER in MB as per standard Mikhmon practice, OR generic string.
// We'll pass as is for strings, or multiply if strictly numeric?
// Let's rely on standard Mikrotik parsing if string passed, or convert.
// Mikhmon v3 usually uses dropdown "MB/GB".
// Implementing simple conversion:
$val = intval($package['data_limit']);
if (strpos(strtolower($package['data_limit']), 'g') !== false) {
$userData['limit-bytes-total'] = $val * 1024 * 1024 * 1024;
} else {
$userData['limit-bytes-total'] = $val * 1024 * 1024; // Default MB
}
}
$API->comm("/ip/hotspot/user/add", $userData);
$API->disconnect();
} else {
die("Connection failed");
}
// 4. Render Template
$tplModel = new VoucherTemplateModel();
$templates = $tplModel->getAll();
$currentTemplate = $_GET['template'] ?? 'default';
$templateContent = '';
$viewName = 'print/default';
if ($currentTemplate !== 'default') {
$tpl = $tplModel->getById($currentTemplate);
if ($tpl) {
$templateContent = $tpl['content'];
$viewName = 'print/custom';
} else {
$currentTemplate = 'default';
}
}
// Calculate bytes for display
$dlVal = intval($package['data_limit']);
$bytes = (strpos(strtolower($package['data_limit']), 'g') !== false) ? $dlVal * 1024*1024*1024 : $dlVal * 1024*1024;
$userDataValues = [
'username' => $username,
'password' => $password,
'price' => $package['price'],
'validity' => $package['time_limit'],
'timelimit' => \App\Helpers\HotspotHelper::formatValidity($package['time_limit']),
'datalimit' => \App\Helpers\HotspotHelper::formatBytes($bytes),
'profile' => $package['profile'],
'comment' => 'Quick Print',
'hotspotname' => $creds['hotspot_name'],
'dns_name' => $creds['dns_name'],
'login_url' => (preg_match("~^(?:f|ht)tps?://~i", $creds['dns_name']) ? $creds['dns_name'] : "http://" . $creds['dns_name']) . "/login"
];
// --- Logo Handling ---
$logoModel = new \App\Models\Logo();
$logos = $logoModel->getAll();
$logoMap = [];
foreach ($logos as $l) {
$logoMap[$l['id']] = $l['path'];
}
$data = [
'users' => [$userDataValues],
'templates' => $templates,
'currentTemplate' => $currentTemplate,
'templateContent' => $templateContent,
'session' => $session,
'logoMap' => $logoMap
];
return $this->view($viewName, $data);
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
use App\Helpers\HotspotHelper;
class ReportController extends Controller
{
public function index($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
header('Location: /');
exit;
}
$API = new RouterOSAPI();
$users = [];
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
// Fetch All Users
// Optimized print: get .id, name, price, comment
$users = $API->comm("/ip/hotspot/user/print");
$API->disconnect();
}
// Aggregate Data
$report = [];
$totalIncome = 0;
$totalVouchers = 0;
foreach ($users as $user) {
// Skip if no price
if (empty($user['price']) || $user['price'] == '0') continue;
// Determine Date from Comment
// Mikhmon format usually: "mikhmon-MM/DD/YYYY" or just "MM/DD/YYYY" or plain comment
// We will try to parse a date from the comment, or use "Unknown Date"
$date = 'Unknown Date';
$comment = $user['comment'] ?? '';
// Regex for date patterns (d-m-Y or m/d/Y or Y-m-d)
// Simplify: Group by Comment content itself if it looks like a date/batch
// Or try to extract M-Y.
// For feature parity, Mikhmon often groups by the exact comment string as the "Batch/Date"
if (!empty($comment)) {
$date = $comment;
}
if (!isset($report[$date])) {
$report[$date] = [
'date' => $date,
'count' => 0,
'total' => 0
];
}
$price = intval($user['price']);
$report[$date]['count']++;
$report[$date]['total'] += $price;
$totalIncome += $price;
$totalVouchers++;
}
// Sort by key (Date/Comment) desc
krsort($report);
return $this->view('reports/selling', [
'session' => $session,
'report' => $report,
'totalIncome' => $totalIncome,
'totalVouchers' => $totalVouchers,
'currency' => $config['currency'] ?? 'Rp'
]);
}
public function resume($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
header('Location: /');
exit;
}
$API = new RouterOSAPI();
$users = [];
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$users = $API->comm("/ip/hotspot/user/print");
$API->disconnect();
}
// Initialize Aggregates
$daily = [];
$monthly = [];
$yearly = [];
$totalIncome = 0;
foreach ($users as $user) {
if (empty($user['price']) || $user['price'] == '0') continue;
// Try to parse Date from Comment (Mikhmon format: mikhmon-10/25/2023 or just 10/25/2023)
$comment = $user['comment'] ?? '';
$dateObj = null;
// Simple parser: try to find MM/DD/YYYY
if (preg_match('/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})/', $comment, $matches)) {
// Assuming MM/DD/YYYY based on typical Mikhmon, but could be DD-MM-YYYY
// Let's standardise on checking valid date.
// Standard Mikhmon V3 is MM/DD/YYYY.
$m = $matches[1];
$d = $matches[2];
$y = $matches[3];
if (strlen($y) == 2) $y = '20' . $y;
$dateObj = \DateTime::createFromFormat('m/d/Y', "$m/$d/$y");
}
// Fallback: If no date found in comment, maybe created at?
// Usually Mikhmon relies strictly on comment.
if (!$dateObj) continue;
$price = intval($user['price']);
$totalIncome += $price;
// Formats
$dayKey = $dateObj->format('Y-m-d');
$monthKey = $dateObj->format('Y-m');
$yearKey = $dateObj->format('Y');
// Daily
if (!isset($daily[$dayKey])) $daily[$dayKey] = 0;
$daily[$dayKey] += $price;
// Monthly
if (!isset($monthly[$monthKey])) $monthly[$monthKey] = 0;
$monthly[$monthKey] += $price;
// Yearly
if (!isset($yearly[$yearKey])) $yearly[$yearKey] = 0;
$yearly[$yearKey] += $price;
}
// Sort Keys
ksort($daily);
ksort($monthly);
ksort($yearly);
return $this->view('reports/resume', [
'session' => $session,
'daily' => $daily,
'monthly' => $monthly,
'yearly' => $yearly,
'totalIncome' => $totalIncome,
'currency' => $config['currency'] ?? 'Rp'
]);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
class SchedulerController extends Controller
{
public function index($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
header('Location: /');
exit;
}
$API = new RouterOSAPI();
$schedulers = [];
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$schedulers = $API->comm("/system/scheduler/print");
$API->disconnect();
}
return $this->view('system/scheduler', [
'session' => $session,
'schedulers' => $schedulers
]);
}
public function store($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) exit;
$API = new RouterOSAPI();
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$API->comm("/system/scheduler/add", [
"name" => $_POST['name'],
"on-event" => $_POST['on_event'],
"start-date" => $_POST['start_date'],
"start-time" => $_POST['start_time'],
"interval" => $_POST['interval'],
"comment" => $_POST['comment'] ?? '',
"disabled" => "no"
]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.schedule_added', 'toasts.schedule_added_desc', [], true);
header("Location: /$session/system/scheduler");
}
public function update($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) exit;
$API = new RouterOSAPI();
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$API->comm("/system/scheduler/set", [
".id" => $_POST['id'],
"name" => $_POST['name'],
"on-event" => $_POST['on_event'],
"start-date" => $_POST['start_date'],
"start-time" => $_POST['start_time'],
"interval" => $_POST['interval'],
"comment" => $_POST['comment'] ?? ''
]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.schedule_updated', 'toasts.schedule_updated_desc', [], true);
header("Location: /$session/system/scheduler");
}
public function delete($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) exit;
$API = new RouterOSAPI();
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$API->comm("/system/scheduler/remove", [
".id" => $_POST['id']
]);
$API->disconnect();
}
\App\Helpers\FlashHelper::set('success', 'toasts.schedule_deleted', 'toasts.schedule_deleted_desc', [], true);
header("Location: /$session/system/scheduler");
}
}

View File

@@ -0,0 +1,462 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Core\Middleware;
use App\Helpers\FormatHelper;
class SettingsController extends Controller {
public function __construct() {
Middleware::auth();
}
public function system() {
// Systems Settings Tab (Admin, Global, Backup)
$settingModel = new \App\Models\Setting();
$settings = $settingModel->getAll();
$username = $_SESSION['username'] ?? 'admin';
return $this->view('settings/systems', [
'settings' => $settings,
'username' => $username
]);
}
public function routers() {
// Routers List Tab
$configModel = new Config();
$routers = $configModel->getAllSessions();
return $this->view('settings/index', ['routers' => $routers]);
}
public function add() {
return $this->view('settings/form');
}
// ... (Existing Store methods) ...
public function store() {
// Sanitize Session Name (Duplicate Frontend Logic)
$rawSess = $_POST['sessname'] ?? '';
$sessName = preg_replace('/[^a-z0-9-]/', '', strtolower(str_replace(' ', '-', $rawSess)));
$data = [
'session_name' => $sessName,
'ip_address' => $_POST['ipmik'],
'username' => $_POST['usermik'],
'password' => $_POST['passmik'],
'hotspot_name' => $_POST['hotspotname'],
'dns_name' => $_POST['dnsname'],
'currency' => $_POST['currency'],
'reload_interval' => $_POST['areload'],
'interface' => $_POST['iface'],
'description' => 'Added via Remake',
'quick_access' => isset($_POST['quick_access']) ? 1 : 0
];
$configModel = new Config();
try {
$configModel->addSession($data);
$redirect = '/settings/routers';
if (isset($_POST['action']) && $_POST['action'] === 'connect') {
$redirect = '/' . urlencode($data['session_name']) . '/dashboard';
}
\App\Helpers\FlashHelper::set('success', 'toasts.router_added', 'toasts.router_added_desc', ['name' => $data['session_name']], true);
header("Location: $redirect");
} catch (\Exception $e) {
echo "Error adding session: " . $e->getMessage();
}
}
// Update Admin Password
public function updateAdmin() {
$newPassword = $_POST['admin_password'] ?? '';
if (!empty($newPassword)) {
$db = \App\Core\Database::getInstance();
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
// Assuming we are updating the default 'admin' user or the currently logged in user
// Original Mikhmon usually has one main user. Let's update 'admin' for now.
$db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]);
\App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true);
}
header('Location: /settings/system');
}
// Update Global Settings
public function updateGlobal() {
$settingModel = new \App\Models\Setting();
if (isset($_POST['quick_print_mode'])) {
$settingModel->set('quick_print_mode', $_POST['quick_print_mode']);
\App\Helpers\FlashHelper::set('success', 'toasts.settings_saved', 'toasts.settings_saved_desc', [], true);
}
header('Location: /settings/system');
}
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() {
$id = $_POST['id'];
// Sanitize Session Name
$rawSess = $_POST['sessname'] ?? '';
$sessName = preg_replace('/[^a-z0-9-]/', '', strtolower(str_replace(' ', '-', $rawSess)));
$data = [
'session_name' => $sessName,
'ip_address' => $_POST['ipmik'],
'username' => $_POST['usermik'],
'password' => $_POST['passmik'], // Can be empty if not changing
'hotspot_name' => $_POST['hotspotname'],
'dns_name' => $_POST['dnsname'],
'currency' => $_POST['currency'],
'reload_interval' => $_POST['areload'],
'interface' => $_POST['iface'],
'description' => 'Updated via Remake',
'quick_access' => isset($_POST['quick_access']) ? 1 : 0
];
$configModel = new Config();
try {
$configModel->updateSession($id, $data);
$redirect = '/settings/routers';
if (isset($_POST['action']) && $_POST['action'] === 'connect') {
$redirect = '/' . urlencode($data['session_name']) . '/dashboard';
}
\App\Helpers\FlashHelper::set('success', 'toasts.router_updated', 'toasts.router_updated_desc', ['name' => $data['session_name']], true);
header("Location: $redirect");
} catch (\Exception $e) {
echo "Error updating session: " . $e->getMessage();
}
}
public function delete() {
$id = $_POST['id'];
$configModel = new Config();
$configModel->deleteSession($id);
\App\Helpers\FlashHelper::set('success', 'toasts.router_deleted', 'toasts.router_deleted_desc', [], true);
header('Location: /settings/routers');
}
public function backup() {
$backupName = 'mivo_backup_' . date('d-m-Y') . '.mivo';
$json = [];
// Backup Settings
$settingModel = new \App\Models\Setting();
$settings = $settingModel->getAll();
$json['settings'] = $settings;
// Backup Sessions
$configModel = new Config();
$sessions = $configModel->getAllSessions();
// Decrypt passwords for portability
foreach ($sessions as &$session) {
if (!empty($session['password'])) {
$session['password'] = \App\Helpers\EncryptionHelper::decrypt($session['password']);
}
}
$json['sessions'] = $sessions;
// Backup Voucher Templates
$templateModel = new \App\Models\VoucherTemplateModel();
$json['voucher_templates'] = $templateModel->getAll();
// Backup Logos
$logoModel = new \App\Models\Logo();
$logos = $logoModel->getAll();
foreach ($logos as &$logo) {
$filePath = ROOT . '/public' . $logo['path'];
if (file_exists($filePath)) {
$logo['data'] = base64_encode(file_get_contents($filePath));
}
}
$json['logos'] = $logos;
// Encode
$jsonString = json_encode($json, JSON_PRETTY_PRINT);
// Encrypt the entire file content for security
// Decrypted data inside (like passwords) remain plaintext relative to the JSON structure
// ensuring portability if decrypted successfully.
$content = \App\Helpers\EncryptionHelper::encrypt($jsonString);
// Force Download
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename='.basename($backupName));
header('Content-Transfer-Encoding: binary');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . strlen($content));
ob_clean();
flush();
echo $content;
exit;
}
public function restore() {
if (!isset($_FILES['backup_file']) || $_FILES['backup_file']['error'] !== UPLOAD_ERR_OK) {
\App\Helpers\FlashHelper::set('error', 'toasts.restore_failed', 'toasts.no_file_selected', [], true);
header('Location: /settings/system');
exit;
}
$file = $_FILES['backup_file'];
$filename = $file['name'];
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$mime = $file['type'];
// Validate Extension & MIME
$allowedExtensions = ['mivo'];
$allowedMimes = ['application/octet-stream', 'text/plain']; // text/plain fallback for some OS/Browsers
if (!in_array($extension, $allowedExtensions) || (!empty($mime) && !in_array($mime, $allowedMimes))) {
\App\Helpers\FlashHelper::set('error', 'toasts.restore_failed', 'toasts.invalid_file_type_mivo', [], true);
header('Location: /settings/system');
exit;
}
$rawValue = file_get_contents($file['tmp_name']);
if (empty($rawValue)) {
\App\Helpers\FlashHelper::set('error', 'toasts.restore_failed', 'toasts.file_empty', [], true);
header('Location: /settings/system');
exit;
}
// Attempt to decrypt. If file is old (JSON plaintext), decrypt() returns it as-is.
$content = \App\Helpers\EncryptionHelper::decrypt($rawValue);
$json = json_decode($content, true);
if (!$json || (!isset($json['settings']) && !isset($json['sessions']))) {
\App\Helpers\FlashHelper::set('error', 'toasts.restore_failed', 'toasts.file_corrupted', [], true);
header('Location: /settings/system');
exit;
}
// Restore Settings
if (isset($json['settings'])) {
$settingModel = new \App\Models\Setting();
// Assuming we check if data exists
// We might need to iterate and update
foreach ($json['settings'] as $key => $val) {
$settingModel->set($key, $val);
}
}
// Restore Sessions
if (isset($json['sessions'])) {
$configModel = new Config();
foreach ($json['sessions'] as $session) {
unset($session['id']); // Let system generate new ID
try {
$configModel->addSession($session);
} catch (\Exception $e) {
error_log("Failed to restore session: " . ($session['session_name'] ?? 'unknown'));
}
}
}
// Restore Voucher Templates
if (isset($json['voucher_templates'])) {
$templateModel = new \App\Models\VoucherTemplateModel();
foreach ($json['voucher_templates'] as $tmpl) {
// Check if template exists by name and session
$db = \App\Core\Database::getInstance();
$existing = $db->query("SELECT id FROM voucher_templates WHERE name = ? AND session_name = ?", [$tmpl['name'], $tmpl['session_name']])->fetch();
if ($existing) {
$templateModel->update($existing['id'], $tmpl);
} else {
$templateModel->add($tmpl);
}
}
}
// Restore Logos
if (isset($json['logos'])) {
$logoModel = new \App\Models\Logo();
$uploadDir = ROOT . '/public/assets/img/logos/';
if (!file_exists($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
foreach ($json['logos'] as $logo) {
if (empty($logo['data'])) continue;
// Decode data
$binaryData = base64_decode($logo['data']);
if (!$binaryData) continue;
// Determine filename (try to keep original ID/name or generate new)
$extension = $logo['type'] ?? 'png';
$filename = $logo['id'] . '.' . $extension;
$targetPath = $uploadDir . $filename;
// Save file
if (file_put_contents($targetPath, $binaryData)) {
// Update DB
$db = \App\Core\Database::getInstance();
$db->query("INSERT INTO logos (id, name, path, type, size) VALUES (:id, :name, :path, :type, :size)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, path=excluded.path, type=excluded.type, size=excluded.size", [
'id' => $logo['id'],
'name' => $logo['name'],
'path' => '/assets/img/logos/' . $filename,
'type' => $extension,
'size' => $logo['size']
]);
}
}
}
\App\Helpers\FlashHelper::set('success', 'toasts.restore_success', 'toasts.restore_success_desc', [], true);
header('Location: /settings/system');
}
// --- Logo Management ---
public function logos() {
$logoModel = new \App\Models\Logo(); // Fully qualified to avoid import issues for now or add import
$logoModel->syncFiles(); // Ensure FS and DB are in sync
$logos = $logoModel->getAll();
// Format size for display (since DB stores raw bytes or maybe we want helper there)
// Actually model stored bytes, we format in View or here.
// Let's format here for consistency with previous view.
foreach ($logos as &$logo) {
$logo['formatted_size'] = FormatHelper::formatBytes($logo['size']);
}
return $this->view('settings/logos', ['logos' => $logos]);
}
public function uploadLogo() {
if (!isset($_FILES['logo_file'])) {
header('Location: /settings/logos');
exit;
}
$logoModel = new \App\Models\Logo();
try {
$logoModel->add($_FILES['logo_file']);
} catch (\Exception $e) {
// Ideally flash error message to session
// 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');
}
public function deleteLogo() {
$id = $_POST['id']; // Changed from filename to id
$logoModel = new \App\Models\Logo();
$logoModel->delete($id);
\App\Helpers\FlashHelper::set('success', 'toasts.logo_deleted', 'toasts.logo_deleted_desc', [], true);
header('Location: /settings/logos');
}
// --- API CORS Management ---
public function apiCors() {
$db = \App\Core\Database::getInstance();
$rules = $db->query("SELECT * FROM api_cors ORDER BY created_at DESC")->fetchAll();
// Decode JSON methods and headers for view
foreach ($rules as &$rule) {
$rule['methods_arr'] = json_decode($rule['methods'], true) ?: [];
$rule['headers_arr'] = json_decode($rule['headers'], true) ?: [];
}
return $this->view('settings/api_cors', ['rules' => $rules]);
}
public function storeApiCors() {
$origin = $_POST['origin'] ?? '';
$methods = isset($_POST['methods']) ? json_encode($_POST['methods']) : '["GET","POST"]';
$headers = isset($_POST['headers']) ? json_encode(array_map('trim', explode(',', $_POST['headers']))) : '["*"]';
$maxAge = (int)($_POST['max_age'] ?? 3600);
if (!empty($origin)) {
$db = \App\Core\Database::getInstance();
$db->query("INSERT INTO api_cors (origin, methods, headers, max_age) VALUES (?, ?, ?, ?)", [
$origin, $methods, $headers, $maxAge
]);
\App\Helpers\FlashHelper::set('success', 'toasts.cors_rule_added', 'toasts.cors_rule_added_desc', ['origin' => $origin], true);
}
header('Location: /settings/api-cors');
}
public function updateApiCors() {
$id = $_POST['id'] ?? null;
$origin = $_POST['origin'] ?? '';
$methods = isset($_POST['methods']) ? json_encode($_POST['methods']) : '["GET","POST"]';
$headers = isset($_POST['headers']) ? json_encode(array_map('trim', explode(',', $_POST['headers']))) : '["*"]';
$maxAge = (int)($_POST['max_age'] ?? 3600);
if ($id && !empty($origin)) {
$db = \App\Core\Database::getInstance();
$db->query("UPDATE api_cors SET origin = ?, methods = ?, headers = ?, max_age = ? WHERE id = ?", [
$origin, $methods, $headers, $maxAge, $id
]);
\App\Helpers\FlashHelper::set('success', 'toasts.cors_rule_updated', 'toasts.cors_rule_updated_desc', ['origin' => $origin], true);
}
header('Location: /settings/api-cors');
}
public function deleteApiCors() {
$id = $_POST['id'] ?? null;
if ($id) {
$db = \App\Core\Database::getInstance();
$db->query("DELETE FROM api_cors WHERE id = ?", [$id]);
\App\Helpers\FlashHelper::set('success', 'toasts.cors_rule_deleted', 'toasts.cors_rule_deleted_desc', [], true);
}
header('Location: /settings/api-cors');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
class SystemController extends Controller
{
// Reboot Router
public function reboot($session)
{
$this->executeCommand($session, '/system/reboot');
}
// Shutdown Router
public function shutdown($session)
{
$this->executeCommand($session, '/system/shutdown');
}
private function executeCommand($session, $command)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
header('Content-Type: application/json');
echo json_encode(['error' => 'Session not found']);
return;
}
$API = new RouterOSAPI();
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$API->write($command);
// Wait for command to be processed before cutting connection
sleep(2);
$API->disconnect();
header('Content-Type: application/json');
echo json_encode(['success' => true]);
} else {
header('Content-Type: application/json');
echo json_encode(['error' => 'Connection failed']);
}
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\VoucherTemplateModel;
use App\Core\Middleware;
class TemplateController extends Controller {
public function __construct() {
Middleware::auth();
}
public function index() {
$templateModel = new VoucherTemplateModel();
$templates = $templateModel->getAll();
$data = [
'templates' => $templates
];
return $this->view('settings/templates/index', $data);
}
public function preview($id) {
$content = '';
if ($id === 'default') {
$content = \App\Helpers\TemplateHelper::getDefaultTemplate();
} else {
$templateModel = new VoucherTemplateModel();
$tpl = $templateModel->getById($id);
if ($tpl) {
$content = $tpl['content'];
}
}
echo \App\Helpers\TemplateHelper::getPreviewPage($content);
}
public function add() {
$logoModel = new \App\Models\Logo();
$logos = $logoModel->getAll();
$logoMap = [];
foreach ($logos as $l) {
$logoMap[$l['id']] = $l['path'];
}
$data = [
'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)'
}
public function store() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$name = $_POST['name'] ?? 'Untitled';
$content = $_POST['content'] ?? '';
// Session context could be 'global' or specific. For now, let's treat settings templates as global or assign to 'global' session name if column exists.
// My migration made 'session_name' NOT NULL.
// I will use 'global' for templates created in Settings.
$data = [
'session_name' => 'global',
'name' => $name,
'content' => $content
];
$templateModel = new VoucherTemplateModel();
$templateModel->add($data);
\App\Helpers\FlashHelper::set('success', 'toasts.template_created', 'toasts.template_created_desc', ['name' => $name], true);
header("Location: /settings/templates");
exit;
}
public function edit($id) {
$templateModel = new VoucherTemplateModel();
$template = $templateModel->getById($id);
if (!$template) {
header("Location: /settings/templates");
exit;
}
$logoModel = new \App\Models\Logo();
$logos = $logoModel->getAll();
$logoMap = [];
foreach ($logos as $l) {
$logoMap[$l['id']] = $l['path'];
}
$data = [
'template' => $template,
'logoMap' => $logoMap
];
return $this->view('settings/templates/edit', $data);
}
public function update() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$id = $_POST['id'] ?? '';
$name = $_POST['name'] ?? '';
$content = $_POST['content'] ?? '';
$data = [
'name' => $name,
'content' => $content
];
$templateModel = new VoucherTemplateModel();
$templateModel->update($id, $data);
\App\Helpers\FlashHelper::set('success', 'toasts.template_updated', 'toasts.template_updated_desc', ['name' => $name], true);
header("Location: /settings/templates");
exit;
}
public function delete() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$id = $_POST['id'] ?? '';
$templateModel = new VoucherTemplateModel();
$templateModel->delete($id);
\App\Helpers\FlashHelper::set('success', 'toasts.template_deleted', 'toasts.template_deleted_desc', [], true);
header("Location: /settings/templates");
exit;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Config;
use App\Libraries\RouterOSAPI;
class TrafficController extends Controller
{
public function monitor($session)
{
// 1. Get Session Config
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
http_response_code(404);
echo json_encode(['error' => 'Session not found']);
return;
}
// 2. Connect to RouterOS
$API = new RouterOSAPI();
// $API->debug = true;
// Fast Fail for Traffic Monitor to prevent blocking PHP server
$API->attempts = 1;
$API->timeout = 2;
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
// 3. Get Interface Name from GET param > Config > default 'ether1'
$interface = $_GET['interface'] ?? $config['interface'] ?? 'ether1';
// 4. Fetch Traffic
// /interface/monitor-traffic interface=ether1 once
$traffic = $API->comm('/interface/monitor-traffic', [
"interface" => $interface,
"once" => "",
]);
$API->disconnect();
// 5. Return JSON
if (!empty($traffic) && !isset($traffic['!trap'])) {
header('Content-Type: application/json');
echo json_encode($traffic[0]);
} else {
echo json_encode(['error' => 'No data']);
}
} else {
http_response_code(500);
echo json_encode(['error' => 'Connection failed']);
}
}
public function getInterfaces($session)
{
// 1. Get Session Config
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
http_response_code(404);
echo json_encode(['error' => 'Session not found']);
return;
}
// 2. Connect
$API = new RouterOSAPI();
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
// 3. Fetch Interfaces
// Use comm() to safely handle response parsing and filtering
$interfaces = $API->comm('/interface/print', [
".proplist" => "name,type"
]);
$API->disconnect();
// 4. Return
header('Content-Type: application/json');
echo json_encode($interfaces);
} else {
http_response_code(500);
echo json_encode(['error' => 'Connection failed']);
}
}
}

31
app/Core/Autoloader.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace App\Core;
class Autoloader {
public static function register() {
spl_autoload_register(function ($class) {
// Convert namespace to full file path
// App\Core\Router -> app/Core/Router.php
// We assume ROOT is defined externally
if (!defined('ROOT')) {
return;
}
$prefix = 'App\\';
$base_dir = ROOT . '/app/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relative_class = substr($class, $len);
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) {
require_once $file;
}
});
}
}

240
app/Core/Console.php Normal file
View File

@@ -0,0 +1,240 @@
<?php
namespace App\Core;
class Console {
// ANSI Color Codes
const COLOR_RESET = "\033[0m";
const COLOR_GREEN = "\033[32m";
const COLOR_YELLOW = "\033[33m";
const COLOR_BLUE = "\033[34m";
const COLOR_GRAY = "\033[90m";
const COLOR_RED = "\033[31m";
const COLOR_BOLD = "\033[1m";
public function run($argv) {
$command = $argv[1] ?? 'help';
$args = array_slice($argv, 2);
$this->printBanner();
switch ($command) {
case 'serve':
$this->commandServe($args);
break;
case 'key:generate':
$this->commandKeyGenerate();
break;
case 'admin:reset':
$this->commandAdminReset($args);
break;
case 'install':
$this->commandInstall($args);
break;
case 'help':
default:
$this->commandHelp();
break;
}
}
private function printBanner() {
echo "\n";
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.0" . self::COLOR_RESET . "\n\n";
}
private function commandServe($args) {
$host = '0.0.0.0';
$port = 8000;
foreach ($args as $arg) {
if (strpos($arg, '--port=') === 0) {
$port = (int) substr($arg, 7);
}
if (strpos($arg, '--host=') === 0) {
$host = substr($arg, 7);
}
}
echo " " . self::COLOR_GREEN . "Server running on:" . self::COLOR_RESET . "\n";
echo " - Local: " . self::COLOR_BLUE . "http://localhost:$port" . self::COLOR_RESET . "\n";
$hostname = gethostname();
$ip = gethostbyname($hostname);
if ($ip !== '127.0.0.1' && $ip !== 'localhost') {
echo " - Network: " . self::COLOR_BLUE . "http://$ip:$port" . self::COLOR_RESET . "\n";
}
echo "\n " . self::COLOR_GRAY . "Press Ctrl+C to stop" . self::COLOR_RESET . "\n\n";
$cmd = sprintf("php -S %s:%d -t public public/index.php", $host, $port);
passthru($cmd);
}
private function commandKeyGenerate() {
echo self::COLOR_YELLOW . "Generating new application key..." . self::COLOR_RESET . "\n";
// Generate 32 bytes of random data for AES-256
$key = bin2hex(random_bytes(16)); // 32 chars hex
$envPath = ROOT . '/.env';
$examplePath = ROOT . '/.env.example';
// Copy example if .env doesn't exist
if (!file_exists($envPath)) {
echo self::COLOR_BLUE . "Copying .env.example to .env..." . self::COLOR_RESET . "\n";
if (file_exists($examplePath)) {
copy($examplePath, $envPath);
} else {
echo self::COLOR_RED . "Error: .env.example not found." . self::COLOR_RESET . "\n";
return;
}
}
// Read .env
$content = file_get_contents($envPath);
// Replace or Append APP_KEY
if (strpos($content, 'APP_KEY=') !== false) {
$newContent = preg_replace(
"/APP_KEY=.*/",
"APP_KEY=$key",
$content
);
} else {
$newContent = $content . "\nAPP_KEY=$key";
}
file_put_contents($envPath, $newContent);
echo self::COLOR_GREEN . "Application key set successfully in .env." . self::COLOR_RESET . "\n";
echo self::COLOR_GRAY . "Key: " . $key . self::COLOR_RESET . "\n";
echo self::COLOR_YELLOW . "Please ensure .env is not committed to version control." . self::COLOR_RESET . "\n";
}
private function commandAdminReset($args) {
$username = 'admin';
$password = $args[0] ?? 'admin';
echo self::COLOR_YELLOW . "Resetting password for user '$username'..." . self::COLOR_RESET . "\n";
try {
$db = \App\Core\Database::getInstance();
$hash = password_hash($password, PASSWORD_DEFAULT);
// Check if user exists first
$check = $db->query("SELECT id FROM users WHERE username = ?", [$username])->fetch();
if ($check) {
$db->query("UPDATE users SET password = ? WHERE username = ?", [$hash, $username]);
echo self::COLOR_GREEN . "Password updated successfully." . self::COLOR_RESET . "\n";
} else {
// Determine if we should create it
echo self::COLOR_YELLOW . "User '$username' not found. Creating..." . self::COLOR_RESET . "\n";
$db->query("INSERT INTO users (username, password, created_at) VALUES (?, ?, ?)", [
$username, $hash, date('Y-m-d H:i:s')
]);
echo self::COLOR_GREEN . "User created successfully." . self::COLOR_RESET . "\n";
}
echo "New Password: " . self::COLOR_BOLD . $password . self::COLOR_RESET . "\n";
} catch (\Exception $e) {
echo self::COLOR_RED . "Error: " . $e->getMessage() . self::COLOR_RESET . "\n";
}
}
private function commandInstall($args) {
echo self::COLOR_BLUE . "=== MIVO Installer ===" . self::COLOR_RESET . "\n";
// 1. Database Migration
echo "Setting up database...\n";
try {
if (\App\Core\Migrations::up()) {
echo self::COLOR_GREEN . "Database schema created successfully." . self::COLOR_RESET . "\n";
}
} catch (\Exception $e) {
echo self::COLOR_RED . "Migration Error: " . $e->getMessage() . self::COLOR_RESET . "\n";
return;
}
// 2. Encryption Key
echo "Generating encryption key...\n";
$envPath = ROOT . '/.env';
$keyExists = false;
if (file_exists($envPath)) {
$envIds = parse_ini_file($envPath);
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mikhmonv3remake_secret_key_32bytes') {
$keyExists = true;
}
}
if (!$keyExists) {
$this->commandKeyGenerate();
} else {
echo self::COLOR_YELLOW . "Secret key already set in .env. Skipping." . self::COLOR_RESET . "\n";
}
// 3. Admin Account
echo "Create Admin Account? [Y/n] ";
$handle = fopen("php://stdin", "r");
$line = trim(fgets($handle));
if (strtolower($line) != 'n') {
echo "Username [admin]: ";
$user = trim(fgets($handle));
if (empty($user)) $user = 'admin';
echo "Password [admin]: ";
$pass = trim(fgets($handle));
if (empty($pass)) $pass = 'admin';
// Re-use admin reset logic slightly modified or called directly
$this->commandAdminReset([$pass]); // Simplification: admin:reset implementation uses hardcoded user='admin' currently, need to update it to support custom username if we want full flexibility.
// Wait, my commandAdminReset implementation uses hardcoded 'admin'.
// I should update commandAdminReset to accept username as argument or just replicate logic here.
// Replicating logic for clarity here.
/* Actually, commandAdminReset as currently implemented takes password as arg[0] and uses 'admin' as username.
User requested robust install. I will just run the logic manually here to respect the inputted username. */
try {
$db = \App\Core\Database::getInstance();
$hash = password_hash($pass, PASSWORD_DEFAULT);
$check = $db->query("SELECT id FROM users WHERE username = ?", [$user])->fetch();
if ($check) {
$db->query("UPDATE users SET password = ? WHERE username = ?", [$hash, $user]);
echo self::COLOR_GREEN . "User '$user' updated." . self::COLOR_RESET . "\n";
} else {
$db->query("INSERT INTO users (username, password, created_at) VALUES (?, ?, ?)", [$user, $hash, date('Y-m-d H:i:s')]);
echo self::COLOR_GREEN . "User '$user' created." . self::COLOR_RESET . "\n";
}
} catch (\Exception $e) {
echo self::COLOR_RED . "Error creating user: " . $e->getMessage() . self::COLOR_RESET . "\n";
}
}
echo "\n" . self::COLOR_GREEN . "Installation Completed Successfully!" . self::COLOR_RESET . "\n";
echo "You can now run: " . self::COLOR_YELLOW . "php mivo serve" . self::COLOR_RESET . "\n";
}
private function commandHelp() {
echo self::COLOR_YELLOW . "Usage:" . self::COLOR_RESET . "\n";
echo " php mivo [command] [options]\n\n";
echo self::COLOR_YELLOW . "Available commands:" . self::COLOR_RESET . "\n";
echo " " . self::COLOR_GREEN . "install " . self::COLOR_RESET . " Install MIVO (Setup DB & Admin)\n";
echo " " . self::COLOR_GREEN . "serve " . self::COLOR_RESET . " Start the development server\n";
echo " " . self::COLOR_GREEN . "key:generate " . self::COLOR_RESET . " Set the application key\n";
echo " " . self::COLOR_GREEN . "admin:reset " . self::COLOR_RESET . " Reset admin password (default: admin)\n";
echo " " . self::COLOR_GREEN . "help " . self::COLOR_RESET . " Show this help message\n";
echo "\n";
}
}

20
app/Core/Controller.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace App\Core;
class Controller {
public function view($view, $data = []) {
extract($data);
$viewPath = ROOT . '/app/Views/' . $view . '.php';
if (file_exists($viewPath)) {
require_once $viewPath;
} else {
echo "View not found: $view";
}
}
public function redirect($url) {
header("Location: " . $url);
exit();
}
}

40
app/Core/Database.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
namespace App\Core;
use PDO;
use PDOException;
class Database {
private static $instance = null;
private $pdo;
private function __construct() {
$dbPath = ROOT . '/app/Database/database.sqlite';
try {
$this->pdo = new PDO("sqlite:" . $dbPath);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Database Connection Failed: " . $e->getMessage());
}
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection() {
return $this->pdo;
}
// Helper to run query with params
public function query($sql, $params = []) {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
}

48
app/Core/Env.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Core;
class Env {
/**
* Load environment variables from .env file
*
* @param string $path Path to .env file
* @return void
*/
public static function load($path) {
if (!file_exists($path)) {
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Ignore comments
if (strpos(trim($line), '#') === 0) {
continue;
}
// Parse KEY=VALUE
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Handle quoted strings
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
$value = substr($value, 1, -1);
} elseif (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
}
if (!array_key_exists($key, $_SERVER) && !array_key_exists($key, $_ENV)) {
putenv(sprintf('%s=%s', $key, $value));
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
}
}

41
app/Core/Middleware.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Core;
class Middleware {
public static function auth() {
// Assume session is started in index.php
if (!isset($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
}
public static function cors() {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (empty($origin)) return;
$db = Database::getInstance();
// Check for specific origin or wildcard '*'
$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();
}
}
}
}

101
app/Core/Migrations.php Normal file
View File

@@ -0,0 +1,101 @@
<?php
namespace App\Core;
class Migrations {
public static function up() {
$db = Database::getInstance();
$pdo = $db->getConnection();
// 1. Users Table (Admin Credentials)
$pdo->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// 2. Routers (Sessions) Table
$pdo->exec("CREATE TABLE IF NOT EXISTS routers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL UNIQUE,
ip_address TEXT,
username TEXT,
password TEXT,
hotspot_name TEXT,
dns_name TEXT,
currency TEXT DEFAULT 'RP',
reload_interval INTEGER DEFAULT 60,
interface TEXT,
description TEXT,
quick_access INTEGER DEFAULT 0
)");
// 3. Quick Access (Dashboard Shortcuts)
$pdo->exec("CREATE TABLE IF NOT EXISTS quick_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT,
category TEXT DEFAULT 'general',
active INTEGER DEFAULT 1
)");
// 4. Settings (Key-Value Store)
$pdo->exec("CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)");
// 5. Logos (Branding)
$pdo->exec("CREATE TABLE IF NOT EXISTS logos (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
type TEXT,
size INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// 6. Quick Prints (Voucher Printing Profiles)
$pdo->exec("CREATE TABLE IF NOT EXISTS quick_prints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
name TEXT NOT NULL,
server TEXT NOT NULL,
profile TEXT NOT NULL,
prefix TEXT DEFAULT '',
char_length INTEGER DEFAULT 4,
price INTEGER DEFAULT 0,
time_limit TEXT DEFAULT '',
data_limit TEXT DEFAULT '',
comment TEXT DEFAULT '',
color TEXT DEFAULT 'bg-blue-500',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// 7. Voucher Templates
$pdo->exec("CREATE TABLE IF NOT EXISTS voucher_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// 8. API CORS Rules
$pdo->exec("CREATE TABLE IF NOT EXISTS api_cors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL,
methods TEXT DEFAULT '[\"GET\",\"POST\"]',
headers TEXT DEFAULT '[\"*\"]',
max_age INTEGER DEFAULT 3600,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
return true;
}
}

67
app/Core/Router.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
namespace App\Core;
class Router {
protected $routes = [];
public function get($path, $callback) {
$this->routes['GET'][$path] = $callback;
}
public function post($path, $callback) {
$this->routes['POST'][$path] = $callback;
}
public function dispatch($uri, $method) {
$path = parse_url($uri, PHP_URL_PATH);
// Handle subdirectory
$scriptName = dirname($_SERVER['SCRIPT_NAME']);
if (strpos($path, $scriptName) === 0) {
$path = substr($path, strlen($scriptName));
}
$path = '/' . trim($path, '/');
// Global Install Check: Redirect if database is missing
$dbPath = ROOT . '/app/Database/database.sqlite';
if (!file_exists($dbPath)) {
// Whitelist /install route and assets to prevent infinite loop
if ($path !== '/install' && strpos($path, '/assets/') !== 0) {
header('Location: /install');
exit;
}
}
// Check exact match first
if (isset($this->routes[$method][$path])) {
$callback = $this->routes[$method][$path];
return $this->invokeCallback($callback);
}
// Check dynamic routes
foreach ($this->routes[$method] as $route => $callback) {
// Convert route syntax to regex
// e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$#
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route);
$pattern = "#^" . $pattern . "$#";
if (preg_match($pattern, $path, $matches)) {
array_shift($matches); // Remove full match
$matches = array_map('urldecode', $matches);
return $this->invokeCallback($callback, $matches);
}
}
\App\Helpers\ErrorHelper::show(404);
}
protected function invokeCallback($callback, $params = []) {
if (is_array($callback)) {
$controller = new $callback[0]();
$method = $callback[1];
return call_user_func_array([$controller, $method], $params);
}
return call_user_func_array($callback, $params);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Helpers;
class EncryptionHelper {
public static function encrypt($text) {
if (empty($text)) return '';
$key = \App\Config\SiteConfig::getSecretKey();
// Simple OpenSSL encryption
$iv_length = openssl_cipher_iv_length('aes-256-cbc');
$iv = openssl_random_pseudo_bytes($iv_length);
$encrypted = openssl_encrypt($text, 'aes-256-cbc', $key, 0, $iv);
return base64_encode($encrypted . '::' . $iv);
}
public static function decrypt($text) {
if (empty($text)) return '';
$key = \App\Config\SiteConfig::getSecretKey();
try {
$decoded = base64_decode($text, true);
if ($decoded === false) return $text; // Not valid base64
$parts = explode('::', $decoded, 2);
if (count($parts) !== 2) {
return $text; // Not our encrypted format, likely legacy/plain
}
list($encrypted_data, $iv) = $parts;
return openssl_decrypt($encrypted_data, 'aes-256-cbc', $key, 0, $iv);
} catch (\Exception $e) {
return $text; // Fallback
}
}
public static function formatBytes($bytes, $precision = 2) {
$units = array('B', 'KB', 'MB', 'GB', 'TB');
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
// Uncomment one of the following alternatives
$bytes /= pow(1024, $pow);
// $bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Helpers;
class ErrorHelper {
public static function show($code = 404, $message = 'Page Not Found', $description = null) {
http_response_code($code);
// Provide default descriptions for common codes
if ($description === null) {
switch ($code) {
case 403:
$description = "You do not have permission to access this resource.";
break;
case 500:
$description = "Something went wrong on our end. Please try again later.";
break;
case 503:
$description = "Service Unavailable. The server is currently unable to handle the request due to maintenance or overload.";
break;
case 404:
default:
$description = "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.";
break;
}
}
// Variables extracted in view
$errorCode = $code;
$errorMessage = $message;
$errorDescription = $description;
// Ensure strictly NO output before this if keeping clean, but we are in view mode.
// Clean buffer if active to remove partial content
if (ob_get_level()) {
ob_end_clean();
}
require ROOT . '/app/Views/errors/default.php';
exit;
}
public static function showException($exception) {
http_response_code(500);
// Clean output buffer to ensure clean error page
if (ob_get_level()) {
ob_end_clean();
}
require ROOT . '/app/Views/errors/development.php';
exit;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Helpers;
class FlashHelper {
const SESSION_KEY = 'flash_notification';
/**
* Set a flash message.
*
* @param string $type Notification type: 'success', 'error', 'warning', 'info', 'question'
* @param string $title Title of the notification (or i18n key)
* @param string $message (Optional) Body text of the notification (or i18n key)
* @param array $params (Optional) Parameters for translation interpolation
* @param bool $isTranslated Whether to treat title and message as translation keys
*/
public static function set($type, $title, $message = null, $params = [], $isTranslated = false) {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION[self::SESSION_KEY] = [
'type' => $type,
'title' => $title,
'message' => $message,
'params' => $params,
'isTranslated' => $isTranslated
];
}
/**
* Check if a flash message exists.
*
* @return boolean
*/
public static function has() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return isset($_SESSION[self::SESSION_KEY]);
}
/**
* Get the flash message and clear it from session.
*
* @return array|null
*/
public static function get() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (self::has()) {
$notification = $_SESSION[self::SESSION_KEY];
unset($_SESSION[self::SESSION_KEY]);
return $notification;
}
return null;
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Helpers;
class FormatHelper
{
/**
* Convert MikroTik duration string to human readable format.
* Example: "3w1d8h56m19s" -> "3 Weeks 1 Day 8 Hours 56 Minutes 19 Seconds"
*
* @param string $string
* @return string
*/
public static function elapsedTime($string)
{
if (empty($string)) return '-';
// Mikrotik formats:
// 1. "3w1d8h56m19s" (Full)
// 2. "00:05:00" (Simple H:i:s)
// 3. "1d 05:00:00" (Hybrid)
// 4. "sep/02/2023 10:00:00" (Absolute date, rarely used for uptime but useful to catch)
// Maps Mikrotik abbreviations to Human terms (Plural handled in logic)
$maps = [
'w' => 'Week',
'd' => 'Day',
'h' => 'Hour',
'm' => 'Minute',
's' => 'Second'
];
// Result container
$parts = [];
// Check for simple colon format (H:i:s)
if (strpos($string, ':') !== false && strpos($string, 'w') === false && strpos($string, 'd') === false) {
return $string; // Return as is or parse H:i:s if needed
}
// Parse regex for w, d, h, m, s
//preg_match_all('/(\d+)([wdhms])/', $string, $matches, PREG_SET_ORDER);
// Manual parsing to handle mixed cases more robustly or just regex
foreach ($maps as $key => $label) {
if (preg_match('/(\d+)'.$key.'/', $string, $match)) {
$value = intval($match[1]);
if ($value > 0) {
$parts[] = $value . ' ' . $label . ($value > 1 ? 's' : '');
}
}
}
// If no matches found, straightforward return (maybe it's raw seconds or weird format)
if (empty($parts)) {
if ($string === '0s' || $string === '00:00:00') return '-';
return $string;
}
return implode(' ', $parts);
}
/**
* Capitalize each word (Title Case)
* @param string $string
* @return string
*/
public static function capitalize($string)
{
return ucwords(strtolower($string));
}
/**
* Format Currency
* @param int|float $number
* @param string $prefix
* @return string
*/
public static function formatCurrency($number, $prefix = '')
{
return $prefix . ' ' . number_format($number, 0, ',', '.');
}
/**
* Format Bytes to KB, MB, GB
* @param int $bytes
* @param int $precision
* @return string
*/
public static function formatBytes($bytes, $precision = 2)
{
if ($bytes <= 0) return '-';
$base = log($bytes, 1024);
$suffixes = array('B', 'KB', 'MB', 'GB', 'TB');
return round(pow(1024, $base - floor($base)), $precision) . ' ' . $suffixes[floor($base)];
}
/**
* Format Date
* @param string $dateStr
* @param string $format
* @return string
*/
public static function formatDate($dateStr, $format = 'd M Y H:i')
{
if(empty($dateStr)) return '-';
// Handle Mikrotik default date formats if needed, usually they are readable
// e.g. "jan/02/1970 00:00:00"
$time = strtotime($dateStr);
if(!$time) return $dateStr;
return date($format, $time);
}
/**
* Convert Seconds to Human Readable format
* @param int $seconds
* @return string
*/
public static function formatSeconds($seconds) {
if ($seconds <= 0) return '0s';
$w = floor($seconds / 604800);
$d = floor(($seconds % 604800) / 86400);
$h = floor(($seconds % 86400) / 3600);
$m = floor(($seconds % 3600) / 60);
$s = $seconds % 60;
$parts = [];
if ($w > 0) $parts[] = $w . 'w';
if ($d > 0) $parts[] = $d . 'd';
if ($h > 0) $parts[] = $h . 'h';
if ($m > 0) $parts[] = $m . 'm';
if ($s > 0 || empty($parts)) $parts[] = $s . 's';
return implode('', $parts);
}
/**
* Parse MikroTik duration string to Seconds (int)
* Supports: 1d2h3m, 00:00:00, 1d 00:00:00
*/
public static function parseDuration($string) {
if (empty($string)) return 0;
$string = trim($string);
$totalSeconds = 0;
// 1. Handle "00:00:00" or "1d 00:00:00" (Colons)
if (strpos($string, ':') !== false) {
$parts = explode(' ', $string);
$timePart = end($parts); // 00:00:00
// Calc time part
$t = explode(':', $timePart);
if (count($t) === 3) {
$totalSeconds += ($t[0] * 3600) + ($t[1] * 60) + $t[2];
} elseif (count($t) === 2) { // 00:00 (mm:ss or hh:mm? usually hh:mm in routeros logs, but 00:00:59 is uptime)
// Assumption: if 2 parts, treat as MM:SS if small, or HH:MM?
// RouterOS uptime is usually HH:MM:SS. Let's assume standard time ref.
// Actually RouterOS uptime often drops hours if 0.
// SAFE BET: Just Parse standard 3 parts.
$totalSeconds += ($t[0] * 60) + $t[1];
}
// Calc Day part "1d"
if (count($parts) > 1) {
$dayPart = $parts[0]; // 1d
$totalSeconds += intval($dayPart) * 86400;
}
return $totalSeconds;
}
// 2. Handle "1w2d3h4m5s" (Letters)
if (preg_match_all('/(\d+)([wdhms])/', $string, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$val = intval($m[1]);
$unit = $m[2];
switch ($unit) {
case 'w': $totalSeconds += $val * 604800; break;
case 'd': $totalSeconds += $val * 86400; break;
case 'h': $totalSeconds += $val * 3600; break;
case 'm': $totalSeconds += $val * 60; break;
case 's': $totalSeconds += $val; break;
}
}
return $totalSeconds;
}
// 3. Raw number?
return intval($string);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Helpers;
class HotspotHelper
{
/**
* Parse profile on-login script metadata (Mikhmon format)
* Format: :put (",mode,price,validity,selling_price,lock_user,")
*/
public static function parseProfileMetadata($script) {
if (empty($script)) return [];
// Look for :put (",...,") pattern
preg_match('/:put \("([^"]+)"\)/', $script, $matches);
if (isset($matches[1])) {
// Explode CSV: ,mode,price,validity,selling_price,lock_user,
$data = explode(',', $matches[1]);
$clean = function($val) {
return ($val === '0' || $val === '0d' || $val === '0h' || $val === '0m') ? '' : $val;
};
return [
'expired_mode' => $data[1] ?? '',
'price' => $clean($data[2] ?? ''),
'validity' => self::formatValidity($clean($data[3] ?? '')),
'selling_price' => $clean($data[4] ?? ''),
'lock_user' => $data[6] ?? '',
];
}
return [];
}
/**
* Format validity string (e.g., 3d2h5m -> 3d 2h 5m)
*/
public static function formatValidity($val) {
if (empty($val)) return '';
// Insert space after letters
$val = preg_replace('/([a-z]+)/i', '$1 ', $val);
return trim($val);
}
/**
* Format expired mode code to readable text
*/
public static function formatExpiredMode($mode) {
switch ($mode) {
case 'rem': return 'Remove';
case 'ntf': return 'Notice';
case 'remc': return 'Remove & Record';
case 'ntfc': return 'Notice & Record';
default: return $mode;
}
}
/**
* Format bytes to human readable string (KB, MB, GB)
*/
public static function formatBytes($bytes, $precision = 2) {
if (empty($bytes) || $bytes === '0') return '0 B';
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
/**
* Get User Status Code
* Returns: active, limited, locked, expired
*/
public static function getUserStatus($user) {
// 1. Check for specific comment keywords (Highest Priority - usually set by scripts)
$comment = strtolower($user['comment'] ?? '');
// "exp" explicitly means expired by script
if (strpos($comment, 'exp') !== false) {
return 'expired';
}
// 2. Check Data Limit (Quota)
$limitBytes = isset($user['limit-bytes-total']) ? (int)$user['limit-bytes-total'] : 0;
if ($limitBytes > 0) {
$bytesIn = isset($user['bytes-in']) ? (int)$user['bytes-in'] : 0;
$bytesOut = isset($user['bytes-out']) ? (int)$user['bytes-out'] : 0;
if (($bytesIn + $bytesOut) >= $limitBytes) {
return 'limited';
}
}
// 3. Check Disabled state
if (($user['disabled'] ?? 'false') === 'true') {
return 'locked';
}
// 4. Default
return 'active';
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Helpers;
class LanguageHelper
{
/**
* Get list of available languages from public/lang directory
*
* @return array Array of languages with code and name
*/
public static function getAvailableLanguages()
{
$langDir = ROOT . '/public/lang';
$languages = [];
if (!is_dir($langDir)) {
return [];
}
$files = scandir($langDir);
foreach ($files as $file) {
if ($file === '.' || $file === '..') continue;
if (pathinfo($file, PATHINFO_EXTENSION) === 'json') {
$code = pathinfo($file, PATHINFO_FILENAME);
// Read file to get language name if defined, otherwise use code
$content = file_get_contents($langDir . '/' . $file);
$data = json_decode($content, true);
$name = $data['_meta']['name'] ?? strtoupper($code);
$flag = $data['_meta']['flag'] ?? '🌐';
$languages[] = [
'code' => $code,
'name' => $name,
'flag' => $flag
];
}
}
return $languages;
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Helpers;
class TemplateHelper {
public static function getDefaultTemplate() {
return '
<style>
.voucher { width: 250px; background: #fff; padding: 10px; border: 1px solid #ccc; font-family: "Courier New", Courier, monospace; color: #000; }
.header { text-align: center; font-weight: bold; margin-bottom: 5px; font-size: 14px; }
.row { display: flex; justify-content: space-between; margin-bottom: 2px; font-size: 12px; }
.code { font-size: 16px; font-weight: bold; text-align: center; margin: 10px 0; border: 1px dashed #000; padding: 5px; }
.qr { text-align: center; margin-top: 5px; }
</style>
<div class="voucher">
<div class="header">{{server_name}}</div>
<div class="row"><span>Profile:</span> <span>{{profile}}</span></div>
<div class="row"><span>Valid:</span> <span>{{validity}}</span></div>
<div class="row"><span>Price:</span> <span>{{price}}</span></div>
<div class="code">
User: {{username}}<br>
Pass: {{password}}
</div>
<div class="qr">{{qrcode}}</div>
<div style="text-align:center; font-size: 10px; margin-top:5px;">
Login: http://{{dns_name}}/login
</div>
</div>';
}
public static function getMockContent($content) {
if (empty($content)) return '';
// Dummy Data
$dummyData = [
'{{server_name}}' => 'Hotspot',
'{{dns_name}}' => 'hotspot.lan',
'{{username}}' => 'u-5829',
'{{password}}' => '5912',
'{{price}}' => '5.000',
'{{validity}}' => '12 Hours',
'{{profile}}' => 'Small-Packet',
'{{time_limit}}' => '12h',
'{{data_limit}}' => '1 GB',
'{{ip_address}}' => '192.168.88.254',
'{{mac_address}}' => 'AA:BB:CC:DD:EE:FF',
'{{comment}}' => 'Thank You',
'{{copyright}}' => 'Mikhmon',
];
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content);
// QR Code replacement
$content = preg_replace('/\{\{\s*qrcode.*?\}\}/i', '<img src="https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=http://hotspot.lan/login?user=u-5829" style="width:80px;height:80px;display:inline-block;">', $content);
return $content;
}
public static function getPreviewPage($content) {
$mockContent = self::getMockContent($content);
return '
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
body { display: flex; align-items: center; justify-content: center; background-color: transparent; }
#wrapper { display: inline-block; transform-origin: center center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
</style>
</head>
<body>
<div id="wrapper">' . $mockContent . '</div>
<script>
window.addEventListener("load", () => {
const wrap = document.getElementById("wrapper");
if(!wrap) return;
const updateScale = () => {
const w = wrap.offsetWidth;
const h = wrap.offsetHeight;
const winW = window.innerWidth - 24;
const winH = window.innerHeight - 24;
let scale = 1;
if (w > winW || h > winH) {
scale = Math.min(winW / w, winH / h);
} else {
scale = Math.min(winW / w, winH / h);
}
wrap.style.transform = `scale(${scale})`;
};
updateScale();
window.addEventListener("resize", updateScale);
});
</script>
</body>
</html>';
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Helpers;
class ViewHelper
{
/**
* Render a generic badge with icon
* @param string $status (active, locked, expired, limited, etc.)
* @param string|null $label Optional override text
*/
public static function badge($status, $label = null) {
// Define styles for each status key
$styles = [
'active' => ['class' => 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 'icon' => 'check-circle'],
'limited' => ['class' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', 'icon' => 'pie-chart'],
'locked' => ['class' => 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', 'icon' => 'lock'],
'expired' => ['class' => 'bg-accents-2 text-accents-6', 'icon' => 'clock'],
'default' => ['class' => 'bg-blue-100 text-blue-800', 'icon' => 'info']
];
$style = $styles[$status] ?? $styles['default'];
$text = $label ?? ucfirst($status === 'limited' ? 'Quota' : $status);
return sprintf(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s gap-1"><i data-lucide="%s" class="w-3 h-3"></i> %s</span>',
$style['class'],
$style['icon'],
$text
);
}
}

View File

@@ -0,0 +1,324 @@
<?php
namespace App\Libraries;
/*****************************
*
* RouterOS PHP API class v1.6 (Ported to MIVO)
* Original Author: Denis Basta
* Ported by: MIVO Team
*
******************************/
class RouterOSAPI
{
var $debug = false; // Show debug information
var $connected = false; // Connection state
var $port = 8728; // Port to connect to (default 8729 for ssl)
var $ssl = false; // Connect using SSL (must enable api-ssl in IP/Services)
var $timeout = 3; // Connection attempt timeout and data read timeout
var $attempts = 5; // Connection attempt count
var $delay = 3; // Delay between connection attempts in seconds
var $socket; // Variable for storing socket resource
var $error_no; // Variable for storing connection error number, if any
var $error_str; // Variable for storing connection error text, if any
public function __construct() {
// Constructor logic if needed
}
public function isIterable($var)
{
return $var !== null
&& (is_array($var)
|| $var instanceof \Traversable
|| $var instanceof \Iterator
|| $var instanceof \IteratorAggregate
);
}
public function debug($text)
{
if ($this->debug) {
echo $text . "\n";
}
}
public function encodeLength($length)
{
if ($length < 0x80) {
$length = chr($length);
} elseif ($length < 0x4000) {
$length |= 0x8000;
$length = chr(($length >> 8) & 0xFF) . chr($length & 0xFF);
} elseif ($length < 0x200000) {
$length |= 0xC00000;
$length = chr(($length >> 16) & 0xFF) . chr(($length >> 8) & 0xFF) . chr($length & 0xFF);
} elseif ($length < 0x10000000) {
$length |= 0xE0000000;
$length = chr(($length >> 24) & 0xFF) . chr(($length >> 16) & 0xFF) . chr(($length >> 8) & 0xFF) . chr($length & 0xFF);
} elseif ($length >= 0x10000000) {
$length = chr(0xF0) . chr(($length >> 24) & 0xFF) . chr(($length >> 16) & 0xFF) . chr(($length >> 8) & 0xFF) . chr($length & 0xFF);
}
return $length;
}
public function connect($ip, $login, $password)
{
for ($ATTEMPT = 1; $ATTEMPT <= $this->attempts; $ATTEMPT++) {
$this->connected = false;
$PROTOCOL = ($this->ssl ? 'ssl://' : '' );
$context = stream_context_create(array('ssl' => array('ciphers' => 'ADH:ALL', 'verify_peer' => false, 'verify_peer_name' => false)));
$this->debug('Connection attempt #' . $ATTEMPT . ' to ' . $PROTOCOL . $ip . ':' . $this->port . '...');
$this->socket = @stream_socket_client($PROTOCOL . $ip.':'. $this->port, $this->error_no, $this->error_str, $this->timeout, STREAM_CLIENT_CONNECT,$context);
if ($this->socket) {
socket_set_timeout($this->socket, $this->timeout);
$this->write('/login', false);
$this->write('=name=' . $login, false);
$this->write('=password=' . $password);
$RESPONSE = $this->read(false);
if (isset($RESPONSE[0])) {
if ($RESPONSE[0] == '!done') {
if (!isset($RESPONSE[1])) {
// Login method post-v6.43
$this->connected = true;
break;
} else {
// Login method pre-v6.43
$MATCHES = array();
if (preg_match_all('/[^=]+/i', $RESPONSE[1], $MATCHES)) {
if ($MATCHES[0][0] == 'ret' && strlen($MATCHES[0][1]) == 32) {
$this->write('/login', false);
$this->write('=name=' . $login, false);
$this->write('=response=00' . md5(chr(0) . $password . pack('H*', $MATCHES[0][1])));
$RESPONSE = $this->read(false);
if (isset($RESPONSE[0]) && $RESPONSE[0] == '!done') {
$this->connected = true;
break;
}
}
}
}
}
}
fclose($this->socket);
}
sleep($this->delay);
}
if ($this->connected) {
$this->debug('Connected...');
} else {
$this->debug('Error...');
}
return $this->connected;
}
public function disconnect()
{
if( is_resource($this->socket) ) {
fclose($this->socket);
}
$this->connected = false;
$this->debug('Disconnected...');
}
public function parseResponse($response)
{
if (is_array($response)) {
$PARSED = array();
$CURRENT = null;
$singlevalue = null;
foreach ($response as $x) {
if (in_array($x, array('!fatal','!re','!trap'))) {
if ($x == '!re') {
$CURRENT =& $PARSED[];
} else {
$CURRENT =& $PARSED[$x][];
}
} elseif ($x != '!done') {
$MATCHES = array();
if (preg_match_all('/[^=]+/i', $x, $MATCHES)) {
if ($MATCHES[0][0] == 'ret') {
$singlevalue = $MATCHES[0][1];
}
$CURRENT[$MATCHES[0][0]] = (isset($MATCHES[0][1]) ? $MATCHES[0][1] : '');
}
}
}
if (empty($PARSED) && !is_null($singlevalue)) {
$PARSED = $singlevalue;
}
return $PARSED;
} else {
return array();
}
}
public function arrayChangeKeyName(&$array)
{
if (is_array($array)) {
foreach ($array as $k => $v) {
$tmp = str_replace("-", "_", $k);
$tmp = str_replace("/", "_", $tmp);
if ($tmp) {
$array_new[$tmp] = $v;
} else {
$array_new[$k] = $v;
}
}
return $array_new;
} else {
return $array;
}
}
public function read($parse = true)
{
$RESPONSE = array();
$receiveddone = false;
while (true) {
$BYTE = ord(fread($this->socket, 1));
$LENGTH = 0;
if ($BYTE & 128) {
if (($BYTE & 192) == 128) {
$LENGTH = (($BYTE & 63) << 8) + ord(fread($this->socket, 1));
} else {
if (($BYTE & 224) == 192) {
$LENGTH = (($BYTE & 31) << 8) + ord(fread($this->socket, 1));
$LENGTH = ($LENGTH << 8) + ord(fread($this->socket, 1));
} else {
if (($BYTE & 240) == 224) {
$LENGTH = (($BYTE & 15) << 8) + ord(fread($this->socket, 1));
$LENGTH = ($LENGTH << 8) + ord(fread($this->socket, 1));
$LENGTH = ($LENGTH << 8) + ord(fread($this->socket, 1));
} else {
$LENGTH = ord(fread($this->socket, 1));
$LENGTH = ($LENGTH << 8) + ord(fread($this->socket, 1));
$LENGTH = ($LENGTH << 8) + ord(fread($this->socket, 1));
$LENGTH = ($LENGTH << 8) + ord(fread($this->socket, 1));
}
}
}
} else {
$LENGTH = $BYTE;
}
$_ = "";
if ($LENGTH > 0) {
$_ = "";
$retlen = 0;
while ($retlen < $LENGTH) {
$toread = $LENGTH - $retlen;
$_ .= fread($this->socket, $toread);
$retlen = strlen($_);
}
$RESPONSE[] = $_;
$this->debug('>>> [' . $retlen . '/' . $LENGTH . '] bytes read.');
}
if ($_ == "!done") {
$receiveddone = true;
}
$STATUS = socket_get_status($this->socket);
if ($LENGTH > 0) {
$this->debug('>>> [' . $LENGTH . ', ' . $STATUS['unread_bytes'] . ']' . $_);
}
if ((!$this->connected && !$STATUS['unread_bytes']) || ($this->connected && !$STATUS['unread_bytes'] && $receiveddone)) {
break;
}
}
if ($parse) {
$RESPONSE = $this->parseResponse($RESPONSE);
}
return $RESPONSE;
}
public function write($command, $param2 = true)
{
if ($command) {
$data = explode("\n", $command);
foreach ($data as $com) {
$com = trim($com);
fwrite($this->socket, $this->encodeLength(strlen($com)) . $com);
$this->debug('<<< [' . strlen($com) . '] ' . $com);
}
if (gettype($param2) == 'integer') {
fwrite($this->socket, $this->encodeLength(strlen('.tag=' . $param2)) . '.tag=' . $param2 . chr(0));
$this->debug('<<< [' . strlen('.tag=' . $param2) . '] .tag=' . $param2);
} elseif (gettype($param2) == 'boolean') {
fwrite($this->socket, ($param2 ? chr(0) : ''));
}
return true;
} else {
return false;
}
}
public function comm($com, $arr = array())
{
$count = count($arr);
$this->write($com, !$arr);
$i = 0;
if ($this->isIterable($arr)) {
foreach ($arr as $k => $v) {
switch ($k[0]) {
case "?":
$el = "$k=$v";
break;
case "~":
$el = "$k~$v";
break;
default:
$el = "=$k=$v";
break;
}
$last = ($i++ == $count - 1);
$this->write($el, $last);
}
}
return $this->read();
}
public function __destruct()
{
$this->disconnect();
}
// Helpers included in original file
public static function encrypt($string, $key=128) {
$result = '';
for($i=0, $k= strlen($string); $i<$k; $i++) {
$char = substr($string, $i, 1);
$keychar = substr($key, ($i % strlen($key))-1, 1);
$char = chr(ord($char)+ord($keychar));
$result .= $char;
}
return base64_encode($result);
}
public static function decrypt($string, $key=128) {
$result = '';
$string = base64_decode($string);
for($i=0, $k=strlen($string); $i< $k ; $i++) {
$char = substr($string, $i, 1);
$keychar = substr($key, ($i % strlen($key))-1, 1);
$char = chr(ord($char)-ord($keychar));
$result .= $char;
}
return $result;
}
}

171
app/Models/Config.php Normal file
View File

@@ -0,0 +1,171 @@
<?php
namespace App\Models;
class Config {
protected $configPath;
public function __construct() {
// MIVO Standalone Config Path
// Points to /include/config.php within the MIVO directory
$this->configPath = ROOT . '/include/config.php';
}
public function getSession($sessionName) {
// 1. Check SQLite Database First
try {
$db = \App\Core\Database::getInstance();
$stmt = $db->query("SELECT * FROM routers WHERE session_name = ?", [$sessionName]);
$router = $stmt->fetch();
if ($router) {
return [
'ip' => $router['ip_address'],
'ip_address' => $router['ip_address'], // Alias
'user' => $router['username'],
'username' => $router['username'], // Alias
'password' => \App\Helpers\EncryptionHelper::decrypt($router['password']),
'hotspot_name' => $router['hotspot_name'],
'dns_name' => $router['dns_name'],
'currency' => $router['currency'],
'reload' => $router['reload_interval'],
'interface' => $router['interface'],
'info' => $router['description'],
'quick_access' => $router['quick_access'] ?? 0,
'source' => 'sqlite'
];
}
} catch (\Exception $e) {
// Ignore DB error and fallback
}
// 2. Fallback to Legacy Config
if (!file_exists($this->configPath)) {
return null;
}
include $this->configPath;
if (isset($data) && isset($data[$sessionName]) && is_array($data[$sessionName])) {
$s = $data[$sessionName];
return [
'ip' => isset($s[1]) ? explode('!', $s[1])[1] : '',
'ip_address' => isset($s[1]) ? explode('!', $s[1])[1] : '', // Alias
'user' => isset($s[2]) ? explode('@|@', $s[2])[1] : '',
'username' => isset($s[2]) ? explode('@|@', $s[2])[1] : '', // Alias
'password' => isset($s[3]) ? explode('#|#', $s[3])[1] : '',
'hotspot_name' => isset($s[4]) ? explode('%', $s[4])[1] : '',
'dns_name' => isset($s[5]) ? explode('^', $s[5])[1] : '',
'currency' => isset($s[6]) ? explode('&', $s[6])[1] : '',
'reload' => isset($s[7]) ? explode('*', $s[7])[1] : '',
'interface' => isset($s[8]) ? explode('(', $s[8])[1] : '',
'info' => isset($s[9]) ? explode(')', $s[9])[1] : '',
'source' => 'legacy'
];
}
return null;
}
public function getAllSessions() {
// SQLite
try {
$db = \App\Core\Database::getInstance();
$stmt = $db->query("SELECT * FROM routers");
return $stmt->fetchAll();
} catch (\Exception $e) {
return [];
}
}
public function getSessionById($id) {
$db = \App\Core\Database::getInstance();
$stmt = $db->query("SELECT * FROM routers WHERE id = ?", [$id]);
$router = $stmt->fetch();
if ($router) {
return [
'id' => $router['id'],
'session_name' => $router['session_name'],
'ip_address' => $router['ip_address'],
'username' => $router['username'],
'password' => \App\Helpers\EncryptionHelper::decrypt($router['password']),
'hotspot_name' => $router['hotspot_name'],
'dns_name' => $router['dns_name'],
'currency' => $router['currency'],
'reload_interval' => $router['reload_interval'],
'interface' => $router['interface'],
'interface' => $router['interface'],
'description' => $router['description'],
'quick_access' => $router['quick_access'] ?? 0
];
}
return null;
}
public function addSession($data) {
$db = \App\Core\Database::getInstance();
$sql = "INSERT INTO routers (session_name, ip_address, username, password, hotspot_name, dns_name, currency, reload_interval, interface, description, quick_access)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
return $db->query($sql, [
$data['session_name'] ?? 'New Session',
$data['ip_address'] ?? '',
$data['username'] ?? '',
\App\Helpers\EncryptionHelper::encrypt($data['password'] ?? ''),
$data['hotspot_name'] ?? '',
$data['dns_name'] ?? '',
$data['currency'] ?? 'RP',
$data['reload_interval'] ?? 60,
$data['interface'] ?? 'ether1',
$data['description'] ?? '',
$data['quick_access'] ?? 0
]);
}
public function updateSession($id, $data) {
$db = \App\Core\Database::getInstance();
// If password is provided, encrypt it. If empty, don't update it (keep existing).
if (!empty($data['password'])) {
$sql = "UPDATE routers SET session_name=?, ip_address=?, username=?, password=?, hotspot_name=?, dns_name=?, currency=?, reload_interval=?, interface=?, description=?, quick_access=? WHERE id=?";
$params = [
$data['session_name'],
$data['ip_address'],
$data['username'],
\App\Helpers\EncryptionHelper::encrypt($data['password']),
$data['hotspot_name'],
$data['dns_name'],
$data['currency'],
$data['reload_interval'],
$data['interface'],
$data['description'],
$data['quick_access'] ?? 0,
$id
];
} else {
$sql = "UPDATE routers SET session_name=?, ip_address=?, username=?, hotspot_name=?, dns_name=?, currency=?, reload_interval=?, interface=?, description=?, quick_access=? WHERE id=?";
$params = [
$data['session_name'],
$data['ip_address'],
$data['username'],
$data['hotspot_name'],
$data['dns_name'],
$data['currency'],
$data['reload_interval'],
$data['interface'],
$data['description'],
$data['quick_access'] ?? 0,
$id
];
}
return $db->query($sql, $params);
}
public function deleteSession($id) {
$db = \App\Core\Database::getInstance();
return $db->query("DELETE FROM routers WHERE id = ?", [$id]);
}
}

145
app/Models/Logo.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
namespace App\Models;
use PDO;
use Exception;
class Logo {
protected $db;
protected $table = 'logos';
public function __construct() {
$this->db = \App\Core\Database::getInstance();
$this->initTable();
}
// Connect method removed as we use shared instance
private function initTable() {
$query = "CREATE TABLE IF NOT EXISTS {$this->table} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
type TEXT,
size INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)";
$this->db->query($query);
}
public function generateId($length = 6) {
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[rand(0, $charactersLength - 1)];
}
return $randomString;
}
public function getAll() {
$stmt = $this->db->query("SELECT * FROM {$this->table} ORDER BY created_at DESC");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getById($id) {
$stmt = $this->db->query("SELECT * FROM {$this->table} WHERE id = :id", ['id' => $id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function add($file) {
// Security: Strict MIME Type Check
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowedMimes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/svg+xml' => 'svg',
'image/webp' => 'webp'
];
if (!array_key_exists($mimeType, $allowedMimes)) {
throw new Exception("Invalid file type: " . $mimeType);
}
// Use extension mapped from MIME type or sanitize original
// Better to trust MIME mapping for extensions to avoid double extension attacks
$extension = $allowedMimes[$mimeType];
// Generate Unique Short ID
do {
$id = $this->generateId();
$exists = $this->getById($id);
} while ($exists);
$uploadDir = ROOT . '/public/assets/img/logos/';
if (!file_exists($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$filename = $id . '.' . $extension;
$targetPath = $uploadDir . $filename;
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
$this->db->query("INSERT INTO {$this->table} (id, name, path, type, size) VALUES (:id, :name, :path, :type, :size)", [
'id' => $id,
'name' => $file['name'],
'path' => '/assets/img/logos/' . $filename,
'type' => $extension,
'size' => $file['size']
]);
return $id;
}
return false;
}
public function syncFiles() {
// One-time sync: scan folder, if file not in DB, add it.
$logoDir = ROOT . '/public/assets/img/logos/';
if (!file_exists($logoDir)) return;
$files = glob($logoDir . '*.{jpg,jpeg,png,gif,svg}', GLOB_BRACE);
foreach ($files as $file) {
$filename = basename($file);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
// Check if file is registered (maybe by path match)
$webPath = '/assets/img/logos/' . $filename;
$stmt = $this->db->query("SELECT COUNT(*) FROM {$this->table} WHERE path = :path", ['path' => $webPath]);
if ($stmt->fetchColumn() == 0) {
// Not in DB, register it.
// Ideally we'd rename it to a hashID, but since it's existing, let's generate an ID and map it.
do {
$id = $this->generateId();
$exists = $this->getById($id);
} while ($exists);
$this->db->query("INSERT INTO {$this->table} (id, name, path, type, size) VALUES (:id, :name, :path, :type, :size)", [
'id' => $id,
'name' => $filename,
'path' => $webPath,
'type' => $extension,
'size' => filesize($file)
]);
}
}
}
public function delete($id) {
$logo = $this->getById($id);
if ($logo) {
$filePath = ROOT . '/public' . $logo['path'];
if (file_exists($filePath)) {
unlink($filePath);
}
$this->db->query("DELETE FROM {$this->table} WHERE id = :id", ['id' => $id]);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models;
use App\Core\Database;
class QuickPrintModel {
public function getAllBySession($sessionName) {
$db = Database::getInstance();
$stmt = $db->query("SELECT * FROM quick_prints WHERE session_name = ?", [$sessionName]);
return $stmt->fetchAll();
}
public function getById($id) {
$db = Database::getInstance();
$stmt = $db->query("SELECT * FROM quick_prints WHERE id = ?", [$id]);
return $stmt->fetch();
}
public function add($data) {
$db = Database::getInstance();
$sql = "INSERT INTO quick_prints (session_name, name, server, profile, prefix, char_length, price, time_limit, data_limit, comment, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
return $db->query($sql, [
$data['session_name'],
$data['name'],
$data['server'],
$data['profile'],
$data['prefix'] ?? '',
$data['char_length'] ?? 4,
$data['price'] ?? 0,
$data['time_limit'] ?? '',
$data['data_limit'] ?? '',
$data['comment'] ?? '',
$data['color'] ?? 'bg-blue-500'
]);
}
public function update($id, $data) {
$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=?";
return $db->query($sql, [
$data['name'],
$data['server'],
$data['profile'],
$data['prefix'] ?? '',
$data['char_length'] ?? 4,
$data['price'] ?? 0,
$data['time_limit'] ?? '',
$data['data_limit'] ?? '',
$data['comment'] ?? '',
$data['color'] ?? 'bg-blue-500',
$id
]);
}
public function delete($id) {
$db = Database::getInstance();
return $db->query("DELETE FROM quick_prints WHERE id = ?", [$id]);
}
}

46
app/Models/Setting.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Core\Database;
class Setting {
private $db;
private $table = 'settings';
public function __construct() {
$this->db = Database::getInstance();
$this->initTable();
}
private function initTable() {
$sql = "CREATE TABLE IF NOT EXISTS {$this->table} (
key TEXT PRIMARY KEY,
value TEXT
)";
$this->db->query($sql);
}
public function get($key, $default = null) {
$stmt = $this->db->query("SELECT value FROM {$this->table} WHERE key = ?", [$key]);
$row = $stmt->fetch();
return $row ? $row['value'] : $default;
}
public function set($key, $value) {
// SQLite Upsert
$sql = "INSERT INTO {$this->table} (key, value) VALUES (:key, :value)
ON CONFLICT(key) DO UPDATE SET value = excluded.value";
return $this->db->query($sql, ['key' => $key, 'value' => $value]);
}
public function getAll() {
$stmt = $this->db->query("SELECT * FROM {$this->table}");
$results = $stmt->fetchAll();
$settings = [];
foreach ($results as $row) {
$settings[$row['key']] = $row['value'];
}
return $settings;
}
}

24
app/Models/User.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use App\Core\Database;
class User {
private $db;
public function __construct() {
$this->db = Database::getInstance();
}
public function attempt($username, $password) {
$stmt = $this->db->query("SELECT * FROM users WHERE username = ?", [$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
return $user;
}
return false;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use App\Core\Database;
class VoucherTemplateModel {
public function getAll() {
$db = Database::getInstance();
$stmt = $db->query("SELECT * FROM voucher_templates");
return $stmt->fetchAll();
}
public function getBySession($sessionName) {
// Templates can be global or session specific, but allow session filtering
$db = Database::getInstance();
$stmt = $db->query("SELECT * FROM voucher_templates WHERE session_name = ? OR session_name = 'global'", [$sessionName]);
return $stmt->fetchAll();
}
public function getById($id) {
$db = Database::getInstance();
$stmt = $db->query("SELECT * FROM voucher_templates WHERE id = ?", [$id]);
return $stmt->fetch();
}
public function add($data) {
$db = Database::getInstance();
$sql = "INSERT INTO voucher_templates (session_name, name, content) VALUES (?, ?, ?)";
return $db->query($sql, [
$data['session_name'],
$data['name'],
$data['content']
]);
}
public function update($id, $data) {
$db = Database::getInstance();
$sql = "UPDATE voucher_templates SET name=?, content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?";
return $db->query($sql, [
$data['name'],
$data['content'],
$id
]);
}
public function delete($id) {
$db = Database::getInstance();
return $db->query("DELETE FROM voucher_templates WHERE id = ?", [$id]);
}
}

331
app/Views/dashboard.php Normal file
View File

@@ -0,0 +1,331 @@
<?php
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="common.dashboard">Dashboard</h1>
<p class="text-accents-5"><span data-i18n="common.session">Session</span>: <strong class="text-foreground"><?= $session ?></strong></p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- System Info Card -->
<div class="card space-y-5">
<div class="flex items-center gap-2">
<i data-lucide="cpu" class="w-5 h-5"></i>
<h3 class="font-semibold text-lg" data-i18n="dashboard.system_info">System Info</h3>
</div>
<div class="text-sm space-y-2">
<div class="flex justify-between border-b border-accents-2 pb-2">
<span class="text-accents-5" data-i18n="dashboard.model">Model</span>
<span class="font-medium"><?= $routerboard['model'] ?? '-' ?></span>
</div>
<div class="flex justify-between border-b border-accents-2 pb-2">
<span class="text-accents-5" data-i18n="dashboard.board_name">Board Name</span>
<span class="font-medium"><?= $resource['board-name'] ?? '-' ?></span>
</div>
<div class="flex justify-between border-b border-accents-2 pb-2">
<span class="text-accents-5" data-i18n="dashboard.router_os">RouterOS</span>
<span class="font-medium"><?= $resource['version'] ?? '-' ?></span>
</div>
<div class="flex justify-between border-b border-accents-2 pb-2">
<span class="text-accents-5" data-i18n="dashboard.architecture">Architecture</span>
<span class="font-medium"><?= $resource['architecture-name'] ?? '-' ?></span>
</div>
<div class="flex justify-between">
<span class="text-accents-5" data-i18n="dashboard.uptime">Uptime</span>
<span class="font-medium"><?= \App\Helpers\FormatHelper::elapsedTime($resource['uptime'] ?? '-') ?></span>
</div>
</div>
</div>
<!-- Resources Card -->
<div class="card space-y-5">
<div class="flex items-center gap-2">
<i data-lucide="hard-drive" class="w-5 h-5"></i>
<h3 class="font-semibold text-lg" data-i18n="dashboard.resources">Resources</h3>
</div>
<!-- CPU Config (simple progress not calculated here for cpu-load as it fluctuates, just text) -->
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span data-i18n="dashboard.cpu_load">CPU Load</span>
<span class="font-bold"><?= $resource['cpu-load'] ?? 0 ?>%</span>
</div>
<div class="h-2 w-full bg-accents-2 rounded-full overflow-hidden">
<div class="h-full bg-foreground" style="width: <?= $resource['cpu-load'] ?? 0 ?>%"></div>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span data-i18n="dashboard.memory">Memory</span>
<span class="text-accents-5"><?= \App\Helpers\FormatHelper::formatBytes($resource['free-memory']??0, 1) ?> <span data-i18n="dashboard.free">Free</span></span>
</div>
<div class="h-2 w-full bg-accents-2 rounded-full overflow-hidden">
<?php
$totalMem = ($resource['total-memory']??1);
$freeMem = ($resource['free-memory']??0);
$usedMemP = (($totalMem - $freeMem) / $totalMem) * 100;
?>
<div class="h-full bg-blue-600 dark:bg-blue-500" style="width:<?= $usedMemP ?>%"></div>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span data-i18n="dashboard.hdd">HDD</span>
<span class="text-accents-5"><?= \App\Helpers\FormatHelper::formatBytes($resource['free-hdd-space']??0, 1) ?> <span data-i18n="dashboard.free">Free</span></span>
</div>
<div class="h-2 w-full bg-accents-2 rounded-full overflow-hidden">
<?php
$totalHdd = ($resource['total-hdd-space']??1);
$freeHdd = ($resource['free-hdd-space']??0);
$usedHddP = (($totalHdd - $freeHdd) / $totalHdd) * 100;
?>
<div class="h-full bg-foreground" style="width:<?= $usedHddP ?>%"></div>
</div>
</div>
</div>
<!-- Hotspot Stats -->
<div class="col-span-full md:col-span-1 lg:col-span-1 card flex flex-col justify-center space-y-5">
<div class="flex items-center gap-2">
<i data-lucide="wifi" class="w-5 h-5"></i>
<h3 class="font-semibold text-lg" data-i18n="hotspot_menu.hotspot">Hotspot</h3>
</div>
<div class="grid grid-cols-2 md:grid-cols-1 xl:grid-cols-2 gap-4">
<!-- Active Hotspot -->
<div class="sub-card text-center group relative aspect-square flex flex-col justify-center items-center w-full max-w-[140px] mx-auto">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/active" class="absolute inset-0 z-10" title="View Active Users"></a>
<div class="flex justify-center mb-2 text-blue-500 dark:text-blue-400 group-hover:scale-110 transition-transform">
<i data-lucide="activity" class="w-6 h-6"></i>
</div>
<div class="text-2xl font-bold text-foreground"><?= $hotspot_active ?></div>
<div class="text-xs text-accents-5 uppercase tracking-wide font-semibold mt-1" data-i18n="status_menu.active">Active</div>
</div>
<!-- Users -->
<div class="sub-card text-center group relative aspect-square flex flex-col justify-center items-center w-full max-w-[140px] mx-auto">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="absolute inset-0 z-10" title="Manage Users"></a>
<div class="flex justify-center mb-2 text-purple-500 dark:text-purple-400 group-hover:scale-110 transition-transform">
<i data-lucide="users" class="w-6 h-6"></i>
</div>
<div class="text-2xl font-bold text-foreground"><?= htmlspecialchars($hotspot_users['count'] ?? 0) ?></div>
<div class="text-xs text-accents-5 uppercase tracking-wide font-semibold mt-1" data-i18n="hotspot_menu.users">Users</div>
</div>
<!-- Income -->
<div class="sub-card text-center col-span-2 group">
<div class="flex justify-center mb-2 text-yellow-500 dark:text-yellow-400 group-hover:scale-110 transition-transform">
<i data-lucide="dollar-sign" class="w-6 h-6"></i>
</div>
<div class="text-2xl font-bold text-foreground">0</div>
<div class="text-xs text-accents-5 uppercase tracking-wide font-semibold mt-1" data-i18n="dashboard.income_today">Income Today</div>
</div>
</div>
</div>
<!-- Traffic Monitor -->
<div class="col-span-full card space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-4 w-full sm:w-auto">
<div class="flex items-center gap-2">
<i data-lucide="activity" class="w-5 h-5 text-blue-500"></i>
<h3 class="font-semibold text-lg" data-i18n="dashboard.traffic_monitor">Traffic Monitor</h3>
</div>
<div class="relative w-full sm:w-auto">
<select id="traffic-interface" class="custom-select w-full sm:w-48">
<option value="" disabled selected>Loading...</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 text-xs text-accents-5 self-end sm:self-auto">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500"></span> <span data-i18n="dashboard.rx_download">Rx (Download)</span></span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-500"></span> <span data-i18n="dashboard.tx_upload">Tx (Upload)</span></span>
</div>
</div>
<div class="relative h-64 w-full">
<canvas id="trafficChart"></canvas>
</div>
</div>
</div>
<script src="/assets/js/chart.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const ctx = document.getElementById('trafficChart').getContext('2d');
const labels = Array(20).fill('');
const rxData = Array(20).fill(0);
const txData = Array(20).fill(0);
// Chart Configuration
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: window.i18n ? window.i18n.t('dashboard.rx_download') : 'Rx (Download)',
data: rxData,
borderColor: '#3b82f6', // blue-500
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0
},
{
label: window.i18n ? window.i18n.t('dashboard.tx_upload') : 'Tx (Upload)',
data: txData,
borderColor: '#22c55e', // green-500
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false, // Disable animation for smoother realtime updates
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + formatBits(context.raw);
}
}
}
},
scales: {
x: {
display: false,
grid: { display: false }
},
y: {
border: { display: false },
grid: { color: 'rgba(128, 128, 128, 0.1)' },
ticks: {
callback: function(value) {
return formatBits(value);
}
},
beginAtZero: true
}
}
}
});
// Helper: Format Bits
function formatBits(bits) {
if (bits === 0) return '0 bps';
const units = ['bps', 'Kbps', 'Mbps', 'Gbps'];
const i = Math.floor(Math.log(bits) / Math.log(1024));
return parseFloat((bits / Math.pow(1024, i)).toFixed(1)) + ' ' + units[i];
}
// Fetch Data
const session = '<?= htmlspecialchars($session) ?>';
let currentInterface = null; // Will be set after fetching interfaces
async function fetchInterfaces() {
try {
const response = await fetch(`/${session}/traffic/interfaces`);
if (!response.ok) return;
const interfaces = await response.json();
const select = document.getElementById('traffic-interface');
select.innerHTML = ''; // access clean
if (Array.isArray(interfaces)) {
interfaces.forEach(iface => {
const option = document.createElement('option');
option.value = iface.name;
option.textContent = iface.name; // Simple name, can add type if needed
select.appendChild(option);
});
// Set default (ether1 or first one)
// Priority: Configured Interface > ether1 > First available
const configInterface = '<?= $interface ?>'; // From Controller
let defaultIface = null;
if (configInterface && interfaces.find(i => i.name === configInterface)) {
defaultIface = configInterface;
} else if (interfaces.find(i => i.name === 'ether1')) {
defaultIface = 'ether1';
} else {
defaultIface = interfaces[0]?.name;
}
if (defaultIface) {
select.value = defaultIface;
currentInterface = defaultIface;
}
// Refresh Custom Select UI
if (typeof CustomSelect !== 'undefined' && CustomSelect.instances) {
const instance = CustomSelect.instances.find(i => i.originalSelect.id === 'traffic-interface');
if (instance) instance.refresh();
}
}
} catch (err) {
console.error("Interfaces fetch error:", err);
document.getElementById('traffic-interface').innerHTML = '<option>Error</option>';
}
}
// Handle Change
document.getElementById('traffic-interface').addEventListener('change', (e) => {
currentInterface = e.target.value;
// Clear chart for visual feedback? Or just let it transition
rxData.fill(0);
txData.fill(0);
chart.update();
});
async function fetchTraffic() {
if (!currentInterface) return;
try {
// Encode interface name to handle special chars / spaces
const response = await fetch(`/${session}/traffic/monitor?interface=${encodeURIComponent(currentInterface)}`);
if (!response.ok) return; // Silent fail
const data = await response.json();
if (data && !data.error) {
// Update Data (Shift and Push)
chart.data.datasets[0].data.push(parseInt(data['rx-bits-per-second']));
chart.data.datasets[0].data.shift();
chart.data.datasets[1].data.push(parseInt(data['tx-bits-per-second']));
chart.data.datasets[1].data.shift();
chart.update('none'); // Update without animation
}
} catch (err) {
console.error("Traffic fetch error:", err);
}
}
// Init
fetchInterfaces().then(() => {
// Start Polling after interfaces loaded
setInterval(fetchTraffic, 5000); // Every 5 seconds
fetchTraffic();
});
});
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

396
app/Views/design_system.php Normal file
View File

@@ -0,0 +1,396 @@
<?php require_once ROOT . '/app/Views/layouts/header_main.php'; ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold tracking-tight">Design System</h1>
<p class="text-accents-5">Component library and style guide for Mikhmon v3.</p>
</div>
<div class="flex gap-2">
<button onclick="document.documentElement.classList.remove('dark')" class="btn bg-gray-200 text-gray-800">Light</button>
<button onclick="document.documentElement.classList.add('dark')" class="btn bg-gray-800 text-white">Dark</button>
</div>
</div>
<!-- 1. Typography -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Typography</h2>
<div class="card space-y-4">
<div>
<h1 class="text-4xl font-extrabold tracking-tight">Heading 1 (text-4xl)</h1>
<p class="text-sm text-accents-5">Used for landing page titles.</p>
</div>
<div>
<h2 class="text-3xl font-bold tracking-tight">Heading 2 (text-3xl)</h2>
<p class="text-sm text-accents-5">Used for page titles.</p>
</div>
<div>
<h3 class="text-2xl font-bold tracking-tight">Heading 3 (text-2xl)</h3>
<p class="text-sm text-accents-5">Used for section headers.</p>
</div>
<div>
<h4 class="text-xl font-semibold tracking-tight">Heading 4 (text-xl)</h4>
<p class="text-sm text-accents-5">Used for card titles.</p>
</div>
<div>
<p class="text-base text-foreground">Body Text (text-base)</p>
<p class="text-sm text-accents-5">The quick brown fox jumps over the lazy dog. Used for specific content.</p>
</div>
<div>
<p class="text-sm text-foreground">Small Text (text-sm)</p>
<p class="text-sm text-accents-5">The quick brown fox jumps over the lazy dog. Used for descriptions.</p>
</div>
</div>
</section>
<!-- 2. Colors -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Colors (Theming)</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-4 rounded-lg bg-background border border-accents-2">
<div class="font-bold">Background</div>
<div class="text-xs text-accents-5">bg-background</div>
</div>
<div class="p-4 rounded-lg bg-foreground text-background">
<div class="font-bold">Foreground</div>
<div class="text-xs opacity-80">bg-foreground</div>
</div>
<div class="p-4 rounded-lg bg-accents-1 border border-accents-2">
<div class="font-bold">Accents-1</div>
<div class="text-xs text-accents-5">bg-accents-1</div>
</div>
<div class="p-4 rounded-lg bg-accents-2">
<div class="font-bold">Accents-2</div>
<div class="text-xs text-accents-5">bg-accents-2</div>
</div>
<!-- Status Colors -->
<div class="p-4 rounded-lg bg-blue-600 text-white">
<div class="font-bold">Blue (Info)</div>
</div>
<div class="p-4 rounded-lg bg-green-600 text-white">
<div class="font-bold">Green (Success)</div>
</div>
<div class="p-4 rounded-lg bg-yellow-500 text-white">
<div class="font-bold">Yellow (Warning)</div>
</div>
<div class="p-4 rounded-lg bg-red-600 text-white">
<div class="font-bold">Red (Danger)</div>
</div>
</div>
</section>
<!-- 3. Buttons -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Buttons</h2>
<div class="card space-y-4">
<div class="flex flex-wrap gap-4 items-center">
<button class="btn btn-primary">Primary Button</button>
<button class="btn btn-secondary">Secondary Button</button>
<button class="btn btn-danger">Danger Button</button>
<button class="px-4 py-2 text-sm font-medium text-accents-5 hover:text-foreground transition-colors">Ghost Button</button>
</div>
<div class="flex flex-wrap gap-4 items-center">
<button class="btn btn-primary" disabled>Disabled</button>
<button class="btn btn-primary w-full sm:w-auto">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
With Icon
</button>
<button class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2 animate-spin"></i>
Loading
</button>
</div>
</div>
</section>
<!-- 4. Form Elements -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Forms</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card space-y-4">
<h3 class="font-medium text-lg mb-2">Inputs</h3>
<!-- Text Input -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6">Username</label>
<input type="text" class="form-input w-full" placeholder="Enter username">
</div>
<!-- Password Input -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6">Password</label>
<input type="password" class="form-input w-full" value="password123">
</div>
<!-- Select -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6">Role</label>
<div class="relative">
<select class="custom-select w-full">
<option>Administrator</option>
<option>User</option>
<option>Viewer</option>
</select>
<i data-lucide="chevron-down" class="absolute right-3 top-2.5 h-4 w-4 text-accents-4 pointer-events-none"></i>
</div>
</div>
<!-- Textarea -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-6">Description</label>
<textarea class="form-input w-full" rows="3" placeholder="Add some details..."></textarea>
</div>
</div>
<div class="card space-y-4">
<h3 class="font-medium text-lg mb-2">States & Toggles</h3>
<!-- Error State -->
<div class="space-y-1">
<label class="block text-sm font-medium text-red-500">Error Input</label>
<input type="text" class="form-input w-full border-red-500 focus:ring-red-500 focus:border-red-500" value="Invalid Value">
<p class="text-xs text-red-500">This field is required.</p>
</div>
<!-- Disabled State -->
<div class="space-y-1">
<label class="block text-sm font-medium text-accents-4">Disabled Input</label>
<input type="text" class="form-input w-full bg-accents-1 text-accents-4 cursor-not-allowed" value="Cannot edit me" disabled>
</div>
<!-- Checkbox -->
<div class="flex items-center gap-2 mt-4">
<input type="checkbox" id="chk1" class="rounded border-accents-3 text-foreground focus:ring-foreground h-4 w-4">
<label for="chk1" class="text-sm">Enable Features</label>
</div>
</div>
</div>
</section>
<!-- 5. Cards & Layout -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Cards</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Simple Card -->
<div class="card">
<h3 class="font-semibold text-lg mb-2">Simple Card</h3>
<p class="text-sm text-accents-5">Just a div with `card` class.</p>
</div>
<!-- Hover Card -->
<div class="card hover:border-foreground transition-colors cursor-pointer group">
<h3 class="font-semibold text-lg mb-2 group-hover:text-blue-500 transition-colors">Hoverable Card</h3>
<p class="text-sm text-accents-5">Add `hover:border-foreground` for interactive feel.</p>
</div>
<!-- Icon Card -->
<div class="card flex items-start gap-4">
<div class="p-2 bg-accents-1 rounded-lg">
<i data-lucide="box" class="w-6 h-6"></i>
</div>
<div>
<h3 class="font-semibold text-base">Icon Card</h3>
<p class="text-xs text-accents-5">Layout with flexbox.</p>
</div>
</div>
</div>
</section>
<!-- 5b. Nested Cards -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Nested Cards</h2>
<div class="card space-y-6">
<h3 class="font-semibold text-lg">Parent Glass Card</h3>
<p class="text-sm text-accents-5">This is the main container card.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="sub-card">
<h4 class="font-semibold text-base mb-1">Nested Card 1</h4>
<p class="text-xs text-accents-5">Standard content inside a generic sub-card container.</p>
</div>
<div class="sub-card flex items-center gap-3">
<div class="p-2 bg-white/10 rounded-lg">
<i data-lucide="shield" class="w-5 h-5 text-blue-500"></i>
</div>
<div>
<h4 class="font-semibold text-base">Nested with Icon</h4>
<p class="text-xs text-accents-5">Additional context here.</p>
</div>
</div>
</div>
<div class="sub-card p-0 overflow-hidden">
<div class="p-4 border-b border-white/10">
<h4 class="font-semibold">Full Width Sub-Card</h4>
</div>
<div class="p-4">
<p class="text-sm text-accents-5">This sub-card has a header and content area, simulating a mini-panel.</p>
</div>
</div>
</div>
</section>
<!-- 6. Tables -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Data Table</h2>
<div class="card space-y-4">
<h3 class="font-semibold text-lg">Detailed List</h3>
<p class="text-sm text-accents-5 mb-4">Using <code>.table-glass</code> class for a premium look.</p>
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Role</th>
<th class="text-right">Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="flex items-center">
<div class="h-9 w-9 rounded-full bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
JD
</div>
<div>
<div class="font-medium text-foreground">Jane Cooper</div>
<div class="text-xs text-accents-5">jane.cooper@example.com</div>
</div>
</div>
</td>
<td>
<span class="px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20">
Active
</span>
</td>
<td class="text-accents-5">Admin</td>
<td class="text-right">
<button class="p-1 hover:bg-accents-2 rounded text-accents-5 hover:text-foreground transition-colors">
<i data-lucide="more-horizontal" class="w-4 h-4"></i>
</button>
</td>
</tr>
<tr>
<td>
<div class="flex items-center">
<div class="h-9 w-9 rounded-full bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
CW
</div>
<div>
<div class="font-medium text-foreground">Cody Fisher</div>
<div class="text-xs text-accents-5">cody.fisher@example.com</div>
</div>
</div>
</td>
<td>
<span class="px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-accents-2 text-accents-6 border border-accents-3/20">
Offline
</span>
</td>
<td class="text-accents-5">User</td>
<td class="text-right">
<button class="p-1 hover:bg-accents-2 rounded text-accents-5 hover:text-foreground transition-colors">
<i data-lucide="more-horizontal" class="w-4 h-4"></i>
</button>
</td>
</tr>
<tr>
<td>
<div class="flex items-center">
<div class="h-9 w-9 rounded-full bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
EW
</div>
<div>
<div class="font-medium text-foreground">Esther Howard</div>
<div class="text-xs text-accents-5">esther.howard@example.com</div>
</div>
</div>
</td>
<td>
<span class="px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
On Leave
</span>
</td>
<td class="text-accents-5">Editor</td>
<td class="text-right">
<button class="p-1 hover:bg-accents-2 rounded text-accents-5 hover:text-foreground transition-colors">
<i data-lucide="more-horizontal" class="w-4 h-4"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between pt-4 border-t border-white/10">
<div class="text-xs text-accents-5">Showing 1 to 3 of 12 results</div>
<div class="flex gap-2">
<button class="btn btn-secondary py-1 px-3 text-xs h-8">Previous</button>
<button class="btn btn-secondary py-1 px-3 text-xs h-8">Next</button>
</div>
</div>
</div>
</section>
<!-- 7. Alerts & Confirmations -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Alerts & Confirmations (JS Helper)</h2>
<div class="card space-y-4">
<p class="text-sm text-accents-5 mb-4">You can trigger standardized premium alerts using the global <code>Mivo</code> helper.</p>
<div class="flex flex-wrap gap-4 items-center">
<button onclick="Mivo.alert('success', 'Operation Successful', 'Data has been saved to the database.')" class="btn btn-secondary">
Success Alert
</button>
<button onclick="Mivo.alert('error', 'Operation Failed', 'There was a connection error.')" class="btn btn-secondary text-red-500">
Error Alert
</button>
<button onclick="Mivo.alert('warning', 'Low Storage', 'Please check your disk space.')" class="btn btn-secondary text-yellow-500">
Warning Alert
</button>
<button onclick="Mivo.alert('info', 'System Update', 'New features are available.')" class="btn btn-secondary text-blue-500">
Info Alert
</button>
</div>
<div class="pt-4 border-t border-accents-2 mt-4">
<h3 class="font-medium text-lg mb-2">Confirmation Example</h3>
<button onclick="Mivo.confirm('Delete Item?', 'Are you sure? This cannot be undone.', 'Yes, Delete', 'Keep it').then((result) => {
if (result.isConfirmed) {
Mivo.alert('success', 'Deleted!', 'The item has been removed.');
}
})" class="btn btn-danger">
Trigger Confirmation
</button>
</div>
</div>
<!-- Custom Stacking Toasts -->
<section class="mb-12">
<h2 class="text-xl font-semibold mb-4 border-b border-accents-2 pb-2">Stacking Toasts (Custom Helper)</h2>
<div class="card space-y-4">
<p class="text-sm text-accents-5 mb-4">Premium non-disruptive notifications that stack from the bottom-right.</p>
<div class="flex flex-wrap gap-4 items-center">
<button onclick="Mivo.toast('success', 'Operation Successful', 'Your changes have been saved.')" class="btn btn-primary bg-emerald-500 hover:bg-emerald-600 border-none text-white">
Success Toast
</button>
<button onclick="Mivo.toast('error', 'Update Failed', 'An unexpected error occurred.')" class="btn btn-primary bg-red-500 hover:bg-red-600 border-none text-white">
Error Toast
</button>
<button onclick="Mivo.toast('warning', 'Low Resources', 'Disk space is running low.')" class="btn btn-primary bg-amber-500 hover:bg-amber-600 border-none text-white">
Warning Toast
</button>
<button onclick="Mivo.toast('info', 'System Update', 'New features are available.')" class="btn btn-primary bg-blue-500 hover:bg-blue-600 border-none text-white">
Info Toast
</button>
</div>
</div>
</section>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,36 @@
<?php
// Default values if not provided
$errorCode = isset($code) ? $code : 404;
$errorMessage = isset($message) ? $message : 'Page Not Found';
$errorDescription = isset($description) ? $description : "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.";
// Ensure title is set for header.php
$title = "$errorCode - $errorMessage";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex-grow flex flex-col items-center justify-center w-full">
<div class="text-center px-4">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-accents-2 mb-8">
<i data-lucide="alert-triangle" class="w-10 h-10 text-accents-5"></i>
</div>
<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">
<?= $errorDescription ?>
</p>
<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">
Return Home
</a>
<button onclick="history.back()" class="btn btn-secondary w-full sm:w-auto">
Go Back
</button>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Error - MIVO</title>
<!-- Tailwind CSS (Local) -->
<link rel="stylesheet" href="/assets/css/styles.css">
<script src="/assets/js/lucide.min.js"></script>
<style>
/* Force dark mode if needed */
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; }
body { background-color: #000; color: #fff; }
}
/* Critical: Reset potential global tag styles that might break layout */
.dev-layout-header, .dev-layout-footer {
position: relative !important;
width: 100% !important;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
}
/* Ensure code block scrolls nicely */
.custom-scrollbar::-webkit-scrollbar { height: 8px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #0d1117; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #6e7681; }
/* Manual Utilities (Polyfill for missing Tailwind classes) */
.py-16 { padding-top: 4rem !important; padding-bottom: 4rem !important; }
.space-y-16 > :not([hidden]) ~ :not([hidden]) { margin-top: 4rem !important; }
.space-y-8 > :not([hidden]) ~ :not([hidden]) { margin-top: 2rem !important; }
</style>
</head>
<body class="bg-background text-foreground antialiased min-h-screen flex flex-col font-sans selection:bg-red-500/30 overflow-x-hidden">
<!-- Isolated Header -->
<div class="dev-layout-header border-b border-accents-2 bg-background py-4 px-6 flex-none z-50">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Branding -->
<div class="flex items-center gap-2">
<div class="bg-foreground text-background font-black text-lg px-2 py-0.5 rounded leading-tight">
MIVO
</div>
</div>
<div class="h-5 w-px bg-accents-2"></div>
<span class="text-sm font-bold text-red-600 dark:text-red-500 uppercase tracking-widest">
System Error
</span>
</div>
<div class="flex items-center gap-2">
<a href="/" class="p-2 rounded-full text-accents-5 hover:bg-accents-1 hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-accents-2" aria-label="Return to Dashboard" title="Return to Dashboard">
<i data-lucide="home" class="w-4 h-4"></i>
</a>
<button onclick="location.reload()" class="p-2 rounded-full text-accents-5 hover:bg-accents-1 hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-accents-2" aria-label="Reload Application" title="Reload Application">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
<div class="w-px h-4 bg-accents-2 mx-1"></div>
<button id="theme-toggle" class="p-2 rounded-full text-accents-5 hover:bg-accents-1 hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-accents-2" aria-label="Toggle Dark Mode">
<i data-lucide="moon" class="w-4 h-4 hidden dark:block"></i>
<i data-lucide="sun" class="w-4 h-4 block dark:hidden"></i>
</button>
<div class="hidden sm:flex items-center gap-2 text-xs font-mono text-accents-5 bg-accents-1 px-3 py-1.5 rounded-full border border-accents-2">
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
DEV MODE
</div>
</div>
</div>
</div>
<?php
$className = get_class($exception);
$message = $exception->getMessage();
$file = $exception->getFile();
$line = $exception->getLine();
$trace = $exception->getTraceAsString();
// Code Snippet Logic
$snippet = [];
if (file_exists($file)) {
$lines = file($file);
$start = max(0, $line - 6);
$end = min(count($lines), $line + 5);
for ($i = $start; $i < $end; $i++) {
$snippet[$i + 1] = $lines[$i];
}
}
?>
<!-- Main Content -->
<div class="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 flex flex-col justify-center">
<div class="max-w-4xl mx-auto w-full space-y-16">
<!-- Error Card -->
<div class="card !border-red-500/30 !bg-red-50/50 dark:!bg-red-900/10 p-6 md:p-8 shadow-lg transition-all">
<div class="flex flex-col md:flex-row gap-6">
<div class="flex-shrink-0">
<div class="w-12 h-12 md:w-14 md:h-14 flex items-center justify-center bg-red-100 dark:bg-red-900/40 rounded-xl text-red-600 dark:text-red-400 ring-1 ring-red-200 dark:ring-red-800/50 shadow-sm">
<i data-lucide="bomb" class="w-6 h-6 md:w-8 md:h-8"></i>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-600 text-white uppercase tracking-widest shadow-sm">
FATAL EXCEPTION
</span>
<span class="text-xs font-mono text-accents-5">
<?= date('H:i:s') ?>
</span>
</div>
<h2 class="text-sm font-bold text-red-600 dark:text-red-400 break-all font-mono mb-2">
<?= htmlspecialchars($className) ?>
</h2>
<h1 class="text-2xl md:text-3xl font-extrabold text-foreground mb-6 leading-tight">
<?= htmlspecialchars($message) ?>
</h1>
<div class="bg-background border border-accents-2 rounded-lg font-mono text-sm shadow-sm overflow-hidden mt-6">
<div class="bg-accents-1 px-4 py-2 border-b border-accents-2 flex justify-between items-center">
<span class="break-all text-accents-7 text-xs"><?= htmlspecialchars($file) ?></span>
<span class="text-xs font-bold bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400 px-2 py-0.5 rounded">Line <?= $line ?></span>
</div>
<div class="p-0 overflow-x-auto bg-[#0d1117] text-gray-300">
<table class="w-full text-xs md:text-sm">
<?php foreach ($snippet as $num => $code): ?>
<?php $isErrorLine = ($num == $line); ?>
<tr class="<?= $isErrorLine ? 'bg-red-500/20' : '' ?>">
<td class="text-right px-4 py-1 select-none text-gray-600 border-r border-[#30363d] w-12 bg-[#0d1117]"><?= $num ?></td>
<td class="px-4 py-1 whitespace-pre break-normal font-mono <?= $isErrorLine ? 'text-white font-bold' : 'text-gray-300' ?>"><?= htmlspecialchars($code) ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Stack Trace -->
<div class="space-y-8">
<div class="flex items-center justify-between px-1">
<h3 class="text-sm font-semibold text-accents-5 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="activity" class="w-4 h-4"></i>
Stack Trace
</h3>
<button onclick="navigator.clipboard.writeText(document.getElementById('stacktrace').innerText); this.innerHTML = 'Copied!';" class="text-xs btn btn-sm btn-secondary h-8 px-4 transition-all">
Copy Trace
</button>
</div>
<div class="rounded-xl overflow-hidden border border-accents-2 shadow-inner bg-[#0d1117] relative group">
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-[10px] text-gray-500 font-mono">PHP Stack Trace</span>
</div>
<pre id="stacktrace" class="p-4 text-xs font-mono leading-relaxed whitespace-pre-wrap text-gray-300 overflow-x-auto custom-scrollbar max-h-[500px]"><?= htmlspecialchars($trace) ?></pre>
</div>
</div>
</div>
</div>
<!-- Isolated Footer -->
<div class="dev-layout-footer border-t border-accents-2 bg-background py-6 text-center flex-none">
<p class="text-sm text-accents-4 font-medium flex items-center justify-center gap-2">
MIVO Debugger <span class="w-1 h-1 rounded-full bg-accents-3"></span> Environment: <span class="text-foreground font-semibold">Development</span>
</p>
</div>
<script>
lucide.createIcons();
// Theme Toggle Logic
const themeToggleBtn = document.getElementById('theme-toggle');
const html = document.documentElement;
// Check local storage or system preference
const storedTheme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (storedTheme === 'dark' || (!storedTheme && systemDark)) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// Toggle Event
themeToggleBtn.addEventListener('click', () => {
if (html.classList.contains('dark')) {
html.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
html.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
});
</script>
</body>
</html>

90
app/Views/home.php Normal file
View File

@@ -0,0 +1,90 @@
<?php require_once ROOT . '/app/Views/layouts/header_main.php'; ?>
<div class="w-full max-w-4xl mx-auto py-8 md:py-16 px-4 sm:px-6 text-center">
<div class="mb-8 flex justify-center">
<div class="h-16 w-16 bg-transparent rounded-full flex items-center justify-center">
<img src="/assets/img/logo-m.svg" alt="Mikhmon Logo" class="h-16 w-auto block dark:hidden">
<img src="/assets/img/logo-m-dark.svg" alt="Mikhmon Logo" class="h-16 w-auto hidden dark:block">
</div>
</div>
<h1 class="text-4xl font-extrabold tracking-tight mb-4"><?= \App\Config\SiteConfig::APP_FULL_NAME ?></h1>
<p class="text-xl text-accents-5 mb-12 max-w-2xl mx-auto" data-i18n="home.subtitle">
A modern, lightweight MikroTik Hotspot Manager built for performance and simplicity.
</p>
<!-- Action Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl mx-auto mb-16">
<a href="/settings" class="group card hover:border-foreground transition-all duration-200 text-left">
<div class="h-10 w-10 bg-accents-1 rounded-lg flex items-center justify-center mb-4 group-hover:bg-foreground group-hover:text-background transition-colors">
<i data-lucide="server" class="w-5 h-5"></i>
</div>
<h3 class="font-semibold text-lg mb-1" data-i18n="home.manage_routers">Manage Routers</h3>
<p class="text-sm text-accents-5" data-i18n="home.manage_routers_desc">Configure RouterOS connections and view status.</p>
</a>
<a href="<?= \App\Config\SiteConfig::REPO_URL ?>" target="_blank" class="group card hover:border-foreground transition-all duration-200 text-left">
<div class="h-10 w-10 bg-accents-1 rounded-lg flex items-center justify-center mb-4 group-hover:bg-foreground group-hover:text-background transition-colors">
<i data-lucide="github" class="w-5 h-5"></i>
</div>
<h3 class="font-semibold text-lg mb-1" data-i18n="home.source_code">Source Code</h3>
<p class="text-sm text-accents-5" data-i18n="home.source_code_desc">View the project repository and contribute.</p>
</a>
</div>
<!-- Quick Router List if available -->
<?php
$quickRouters = array_filter($routers, function($r) {
return isset($r['quick_access']) && $r['quick_access'] == 1;
});
?>
<?php if (!empty($quickRouters)): ?>
<div class="text-left max-w-4xl mx-auto">
<h2 class="text-sm font-semibold text-accents-5 uppercase tracking-wider mb-4" data-i18n="home.quick_access">Quick Access</h2>
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th scope="col" data-i18n="home.session_name">Session Name</th>
<th scope="col" data-i18n="home.hotspot_name">Hotspot Name</th>
<th scope="col" data-i18n="home.ip_address">IP Address</th>
<th scope="col" class="relative text-right">
<span class="sr-only" data-i18n="common.actions">Actions</span>
</th>
</tr>
</thead>
<tbody>
<?php foreach ($quickRouters as $router): ?>
<tr>
<td>
<div class="flex items-center">
<div class="h-8 w-8 rounded bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
<?= strtoupper(substr($router['session_name'], 0, 2)) ?>
</div>
<div>
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($router['session_name']) ?></div>
<div class="text-xs text-accents-5">ID: <?= $router['id'] ?></div>
</div>
</div>
</td>
<td>
<div class="text-sm text-foreground"><?= htmlspecialchars($router['hotspot_name']) ?></div>
</td>
<td>
<div class="text-sm text-accents-5 font-mono"><?= htmlspecialchars($router['ip_address']) ?></div>
</td>
<td class="text-right text-sm font-medium">
<a href="/<?= htmlspecialchars($router['session_name']) ?>/dashboard" class="btn btn-secondary btn-sm h-8 px-3" data-i18n="common.open">
Open
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

75
app/Views/install.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
$title = 'Install MIVO - Setup';
include ROOT . '/app/Views/layouts/header_public.php';
?>
<!-- Install Container -->
<main class="flex-grow flex items-center justify-center flex-col w-full">
<div class="w-full max-w-full sm:max-w-md z-10 p-4 sm:p-6 animate-fade-in-up">
<div class="text-center mb-6 sm:mb-10">
<!-- Brand / Logo Area -->
<div class="flex justify-center mb-6 sm:mb-8">
<div class="relative group">
<!-- <div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div> -->
<img src="/assets/img/logo-m.svg" alt="MIVO Logo" class="relative h-10 sm:h-12 w-auto block dark:hidden transform transition-transform duration-300 group-hover:scale-105">
<img src="/assets/img/logo-m-dark.svg" alt="MIVO Logo" class="relative h-10 sm:h-12 w-auto hidden dark:block transform transition-transform duration-300 group-hover:scale-105">
</div>
</div>
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight mb-2">Welcome to MIVO</h1>
<p class="text-accents-5 text-sm">System Installation & Setup</p>
</div>
<div class="card p-6 sm:p-8 space-y-6">
<form action="/install" method="POST" class="space-y-6">
<!-- Steps UI -->
<div class="space-y-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-foreground text-background text-xs font-bold mt-0.5">1</div>
<div>
<h3 class="font-medium text-sm text-foreground">Database Setup</h3>
<p class="text-xs text-accents-5 mt-0.5">Tables will be created automatically (SQLite).</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-foreground text-background text-xs font-bold mt-0.5">2</div>
<div>
<h3 class="font-medium text-sm text-foreground">Encryption Key</h3>
<p class="text-xs text-accents-5 mt-0.5">Secure key generation for passwords.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-foreground text-background text-xs font-bold mt-0.5">3</div>
<div class="w-full">
<h3 class="font-medium text-sm text-foreground mb-3">Admin Account</h3>
<div class="space-y-3">
<div class="space-y-1">
<label class="text-xs font-medium text-accents-5">Username</label>
<input type="text" name="username" value="admin" class="form-input" required>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-accents-5">Password</label>
<input type="password" name="password" placeholder="Min. 8 characters" class="form-input" required>
</div>
</div>
</div>
</div>
</div>
<div class="pt-2">
<button type="submit" class="w-full btn btn-primary h-10 shadow-lg hover:shadow-primary/20">
Install MIVO
</button>
</div>
</form>
</div>
</div>
</main>
<?php include ROOT . '/app/Views/layouts/footer_public.php'; ?>

View File

@@ -0,0 +1,226 @@
<?php if (isset($session) && !empty($session)): ?>
</div> <!-- /.max-w-7xl (Sidebar content) -->
</main>
</div> <!-- /.flex-col (Main Content Wrapper) -->
</div> <!-- /.flex h-screen (Sidebar Layout Root) -->
<?php else: ?>
</div> <!-- /.container (Navbar Global) -->
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200">
<div class="max-w-7xl mx-auto px-4 py-6 text-center text-sm text-accents-5">
<p><?= \App\Config\SiteConfig::getFooter() ?></p>
</div>
</footer>
<?php endif; ?>
<script>
// Global Theme Toggle Logic (Class-based for multiple instances)
document.addEventListener('DOMContentLoaded', () => {
const toggleButtons = document.querySelectorAll('.theme-toggle');
// Function to update all icons based on current mode
const updateIcons = (isDark) => {
const darkIcons = document.querySelectorAll('.theme-toggle-dark-icon');
const lightIcons = document.querySelectorAll('.theme-toggle-light-icon');
if (isDark) {
darkIcons.forEach(el => el.classList.add('hidden'));
lightIcons.forEach(el => el.classList.remove('hidden'));
} else {
darkIcons.forEach(el => el.classList.remove('hidden'));
lightIcons.forEach(el => el.classList.add('hidden'));
}
};
// Initial Check
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
updateIcons(true);
} else {
updateIcons(false);
}
// Click Handlers
toggleButtons.forEach(btn => {
btn.addEventListener('click', function() {
// Update LocalStorage & HTML Class
if (localStorage.theme === 'dark') {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
updateIcons(false);
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
updateIcons(true);
}
});
});
// Sidebar Toggle Logic
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const sidebarClose = document.getElementById('sidebar-close');
if (sidebar && mobileMenuToggle) {
const toggleSidebar = () => {
const isClosed = sidebar.classList.contains('-translate-x-full');
if (isClosed) {
// Open
sidebar.classList.remove('-translate-x-full');
sidebarOverlay.classList.remove('hidden');
// Small delay to allow display:block to apply before opacity transition
setTimeout(() => sidebarOverlay.classList.remove('opacity-0'), 10);
} else {
// Close
sidebar.classList.add('-translate-x-full');
sidebarOverlay.classList.add('opacity-0');
setTimeout(() => sidebarOverlay.classList.add('hidden'), 200);
}
};
mobileMenuToggle.addEventListener('click', toggleSidebar);
if (sidebarClose) sidebarClose.addEventListener('click', toggleSidebar);
if (sidebarOverlay) sidebarOverlay.addEventListener('click', toggleSidebar);
}
// Initialize Lucide Icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
<?php if (\App\Helpers\FlashHelper::has()): ?>
<?php $flash = \App\Helpers\FlashHelper::get(); ?>
document.addEventListener('DOMContentLoaded', () => {
// Map Flash Type to Lucide Icon & Color Class
const typeMap = {
'success': { icon: 'check-circle-2', color: 'text-success' },
'error': { icon: 'x-circle', color: 'text-error' },
'warning': { icon: 'alert-triangle', color: 'text-warning' },
'info': { icon: 'info', color: 'text-info' },
'question':{ icon: 'help-circle', color: 'text-question' }
};
const type = '<?= $flash['type'] ?>';
const config = typeMap[type] || typeMap['info'];
let title = '<?= addslashes($flash['title']) ?>';
let message = '<?= addslashes($flash['message'] ?? '') ?>';
const params = <?= json_encode($flash['params'] ?? []) ?>;
const isTranslated = <?= $flash['isTranslated'] ? 'true' : 'false' ?>;
const showFlash = () => {
if (isTranslated && window.i18n) {
title = window.i18n.t(title, params);
message = window.i18n.t(message, params);
}
// Use Toasts for all flash notifications
Mivo.toast(type, title, message);
};
if (window.i18n && window.i18n.ready) {
window.i18n.ready.then(showFlash);
} else {
showFlash();
}
});
<?php endif; ?>
</script>
<script>
// Global Dropdown & Sidebar Logic
function toggleMenu(menuId, button) {
const menu = document.getElementById(menuId);
if (!menu) return;
// Handle Dropdowns (IDs start with 'lang-' or 'session-')
if (menuId.startsWith('lang-') || menuId === 'session-dropdown') {
if (menu.classList.contains('invisible')) {
// Open
menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
menu.classList.add('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
} else {
// Close
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
}
return;
}
// Handle Collapsible (Max-Height + Fade for Navbar)
const isOpening = menu.style.maxHeight === '0px' || menu.style.maxHeight === '';
const chevron = button.querySelector('[data-lucide="chevron-down"]');
const burger = button.querySelector('[data-lucide="menu"]');
if (isOpening) {
menu.style.maxHeight = menu.scrollHeight + "px";
if (chevron) chevron.classList.add('rotate-180');
if (burger) burger.classList.add('rotate-90');
if (menuId === 'mobile-navbar-menu') {
menu.classList.remove('opacity-0', 'invisible');
menu.classList.add('opacity-100', 'visible');
}
} else {
menu.style.maxHeight = "0px";
if (chevron) chevron.classList.remove('rotate-180');
if (burger) burger.classList.remove('rotate-90');
if (menuId === 'mobile-navbar-menu') {
menu.classList.add('opacity-0', 'invisible');
menu.classList.remove('opacity-100', 'visible');
}
}
}
// Close dropdowns when clicking outside
document.addEventListener('click', function(event) {
const dropdowns = document.querySelectorAll('[id^="lang-dropdown"], #session-dropdown');
dropdowns.forEach(dropdown => {
if (!dropdown.classList.contains('invisible')) {
// 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).
const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`);
if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) {
dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
}
}
});
});
// Helper for confirm actions
async function confirmAction(url, message) {
const title = message.includes('Reboot') ? 'Reboot Router?' : 'Shutdown Router?';
const okText = message.includes('Reboot') ? 'Reboot' : 'Shutdown';
const confirmed = await Mivo.confirm(title, message, okText, 'Cancel');
if (!confirmed) return;
try {
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (data.success) {
Mivo.toast('success', title.replace('?', ''), 'The command has been sent to the router.');
} else {
Swal.fire({
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) {
Mivo.toast('error', 'Connection Error', 'Failed to reach the server.');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,82 @@
<footer class="mt-auto py-6 text-center text-xs text-accents-5 opacity-60">
<?= \App\Config\SiteConfig::getFooter() ?>
</footer>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Initialize Lucide Icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
<?php if (\App\Helpers\FlashHelper::has()): ?>
<?php $flash = \App\Helpers\FlashHelper::get(); ?>
document.addEventListener('DOMContentLoaded', () => {
// Map Flash Type to Lucide Icon & Color Class
const typeMap = {
'success': { icon: 'check-circle-2', color: 'text-success' },
'error': { icon: 'x-circle', color: 'text-error' },
'warning': { icon: 'alert-triangle', color: 'text-warning' },
'info': { icon: 'info', color: 'text-info' },
'question':{ icon: 'help-circle', color: 'text-question' }
};
const type = '<?= $flash['type'] ?>';
const config = typeMap[type] || typeMap['info'];
let title = '<?= addslashes($flash['title']) ?>';
let message = '<?= addslashes($flash['message'] ?? '') ?>';
const params = <?= json_encode($flash['params'] ?? []) ?>;
const isTranslated = <?= $flash['isTranslated'] ? 'true' : 'false' ?>;
const showFlash = () => {
if (isTranslated && window.i18n) {
title = window.i18n.t(title, params);
message = window.i18n.t(message, params);
}
// Use Custom Toasts for most notifications (Success, Info, Error)
// Only use Modal (Swal) for specific heavy warnings or questions if needed
if (['success', 'info', 'error', 'warning'].includes(type)) {
// Assuming Mivo.toast is available globally or via another script check
if (window.Mivo && window.Mivo.toast) {
Mivo.toast(type, title, message);
} else {
console.log('Toast:', title, message);
}
} else {
// Use Swal for 'question' or fallback
if (typeof Swal !== 'undefined') {
Swal.fire({
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`,
title: title,
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}`);
}
}
};
if (window.i18n && window.i18n.ready) {
window.i18n.ready.then(showFlash);
} else {
showFlash();
}
});
<?php endif; ?>
</script>
</body>
</html>

View File

@@ -0,0 +1,135 @@
<?php
// Initialize variables to avoid undefined notices if not set
$hotspotname = isset($hotspotname) ? $hotspotname : \App\Config\SiteConfig::APP_NAME;
$themecolor = isset($themecolor) ? $themecolor : '#000000';
$theme = 'light'; // Default theme
$title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $title; ?></title>
<meta name="theme-color" content="<?= $themecolor ?>" />
<!-- Icons -->
<link rel="icon" href="/assets/img/favicon.png" />
<!-- Tailwind CSS (Local) -->
<link rel="stylesheet" href="/assets/css/styles.css">
<!-- Flag Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" />
<style>
@font-face {
font-family: 'Geist';
src: url('/assets/fonts/Geist-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Geist';
src: url('/assets/fonts/Geist-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Geist Mono';
src: url('/assets/fonts/GeistMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
</style>
<script>
// Check local storage or system preference on load
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
</script>
<script src="/assets/js/jquery.min.js"></script>
<script src="/assets/js/lucide.min.js"></script>
<script src="/assets/js/custom-select.js" defer></script>
<script src="/assets/js/datatable.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/i18n.js" defer></script>
<script src="/assets/js/i18n.js" defer></script>
<style>
/* Global Form Input Style - Matches Vercel Design System */
.form-input, .form-control {
display: flex;
align-items: center;
height: 2.5rem; /* 10px */
width: 100%;
border-radius: 0.375rem; /* 6px */
border: 1px solid var(--accents-2, #eaeaea);
background-color: var(--background, #ffffff);
padding-left: 0.75rem;
padding-right: 0.75rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
font-size: 0.875rem; /* 14px */
line-height: 1.25rem;
color: var(--foreground, #000);
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* Input with left icon spacing */
.form-input.pl-10, .form-control.pl-10 {
padding-left: 2.5rem;
}
.dark .form-input {
background-color: #000; /* or darkest gray */
border-color: #333;
color: #fff;
}
.form-input:focus {
outline: none;
border-color: var(--foreground);
box-shadow: 0 0 0 1px var(--foreground);
}
.form-input::placeholder {
color: var(--accents-4);
}
/* Fix for DataTables or other inputs without Left Icon */
input.form-input:not([class*="pl-"]) {
padding-left: 0.75rem;
}
</style>
</head>
<body class="flex flex-col min-h-screen bg-background text-foreground anti-aliased relative">
<!-- Background Elements (Global Sci-Fi Grid) -->
<div class="fixed inset-0 z-0 pointer-events-none">
<!-- Subtle Grid Pattern -->
<div class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMCwwLDAsMC4zKSIvPjwvc3ZnPg==')] dark:bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wNSkiLz48L3N2Zz4=')] [mask-image:linear-gradient(to_bottom,white,transparent)]"></div>
<div class="absolute -top-[20%] -left-[10%] w-[70vw] h-[70vw] rounded-full bg-blue-500/20 dark:bg-blue-500/5 blur-[120px] animate-pulse" style="animation-duration: 4s;"></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>
<?php
if (isset($session) && !empty($session)) {
// Session Layout (Sidebar)
include ROOT . '/app/Views/layouts/sidebar_session.php';
} else {
// Global Layout (Navbar)
include ROOT . '/app/Views/layouts/navbar_main.php';
if (!isset($no_main_container) || !$no_main_container) {
echo '<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">';
}
}
?>

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?? 'MIVO' ?></title>
<!-- Tailwind CSS (Local) -->
<link rel="stylesheet" href="/assets/css/styles.css">
<script src="/assets/js/lucide.min.js"></script>
<script src="/assets/js/sweetalert2.all.min.js" defer></script>
<script src="/assets/js/alert-helper.js" defer></script>
<script src="/assets/js/i18n.js" defer></script>
<style>
/* Custom Keyframes */
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fade-in-up 0.4s ease-out forwards;
}
</style>
<script>
// Check local storage for theme
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<body class="bg-background text-foreground antialiased min-h-screen relative overflow-hidden font-sans selection:bg-accents-2 selection:text-foreground flex flex-col">
<!-- Background Elements (Common) -->
<div class="absolute inset-0 z-0 pointer-events-none">
<!-- Subtle Grid Pattern -->
<div class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMCwwLDAsMC4zKSIvPjwvc3ZnPg==')] dark:bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wNSkiLz48L3N2Zz4=')] [mask-image:linear-gradient(to_bottom,white,transparent)]"></div>
<div class="absolute -top-[20%] -left-[10%] w-[70vw] h-[70vw] rounded-full bg-blue-500/20 dark:bg-blue-500/5 blur-[120px] animate-pulse" style="animation-duration: 4s;"></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>
<!-- Floating Theme Toggle (Bottom Right) -->
<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;">
<i data-lucide="moon" class="w-5 h-5 block dark:hidden group-hover:scale-110 transition-transform"></i>
<i data-lucide="sun" class="w-5 h-5 hidden dark:block group-hover:scale-110 transition-transform"></i>
</button>
<script>
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
// Theme Toggle Logic
const themeToggleBtn = document.getElementById('theme-toggle');
const htmlElement = document.documentElement;
if(themeToggleBtn){
themeToggleBtn.addEventListener('click', () => {
if (htmlElement.classList.contains('dark')) {
htmlElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
htmlElement.classList.add('dark');
localStorage.theme = 'dark';
}
});
}
});
</script>

View File

@@ -0,0 +1,127 @@
<?php
// Determine active link state
$uri = $_SERVER['REQUEST_URI'] ?? '/';
?>
<!-- Modern Navbar (Tailwind) -->
<nav class="sticky top-0 z-50 w-full border-b border-accents-2 bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Brand & Desktop Nav -->
<div class="flex items-center gap-8">
<a href="/" class="flex items-center gap-2 group">
<img src="/assets/img/logo-m.svg" alt="<?= \App\Config\SiteConfig::APP_NAME ?> Logo" class="h-6 w-auto block dark:hidden transition-transform group-hover:scale-110">
<img src="/assets/img/logo-m-dark.svg" alt="<?= \App\Config\SiteConfig::APP_NAME ?> Logo" class="h-6 w-auto hidden dark:block transition-transform group-hover:scale-110">
<span class="font-bold text-lg tracking-tight"><?= \App\Config\SiteConfig::APP_NAME ?></span>
</a>
<!-- Desktop Navigation Links (Hidden on Mobile) -->
<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="/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>
<!-- Right side controls -->
<div class="flex items-center gap-3">
<!-- Desktop Control Pill (Hidden on Mobile) -->
<div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform">
<!-- Language Switcher -->
<div class="relative group">
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-nav', this)" title="Change Language">
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
</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 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
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
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">
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
<span><?= $lang['name'] ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<div class="pill-divider"></div>
<!-- Theme Toggle (Segmented) -->
<div class="segmented-switch theme-toggle" title="Toggle Theme">
<div class="segmented-switch-slider"></div>
<div class="segmented-switch-btn theme-toggle-light-icon">
<i data-lucide="sun" class="w-4 h-4" stroke-width="3.5"></i>
</div>
<div class="segmented-switch-btn theme-toggle-dark-icon">
<i data-lucide="moon" class="w-4 h-4" stroke-width="3.5"></i>
</div>
</div>
<?php if(isset($_SESSION['user_id'])): ?>
<div class="pill-divider"></div>
<a href="/logout" class="p-1.5 rounded-lg text-accents-5 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all ml-0.5" title="Logout">
<i data-lucide="log-out" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
</a>
<?php endif; ?>
</div>
<!-- Mobile Menu Toggles -->
<div class="flex md:hidden items-center gap-2">
<!-- Mobile Mode Control Pill (Condensed) -->
<div class="control-pill py-1.5 px-2">
<div class="segmented-switch theme-toggle scale-75" title="Toggle Theme">
<div class="segmented-switch-slider"></div>
<div class="segmented-switch-btn theme-toggle-light-icon"><i data-lucide="sun" class="w-4 h-4" stroke-width="3.5"></i></div>
<div class="segmented-switch-btn theme-toggle-dark-icon"><i data-lucide="moon" class="w-4 h-4" stroke-width="3.5"></i></div>
</div>
</div>
<button type="button" class="p-2 rounded-lg bg-accents-1 text-accents-5 hover:text-foreground transition-colors group" onclick="toggleMenu('mobile-navbar-menu', this)">
<i data-lucide="menu" class="w-5 h-5 !text-black dark:!text-white transition-transform duration-300" stroke-width="2.5"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Mobile Navigation Drawer (Hidden by default) -->
<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">
<!-- Nav Links -->
<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' ?>">
<i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
<span>Home</span>
</a>
<a href="/settings" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= (strpos($uri, '/settings') === 0) ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>">
<i data-lucide="settings" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
<span>Settings</span>
</a>
</div>
<!-- Mobile Controls Overlay -->
<div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-accents-4 uppercase tracking-wider">Select Language</span>
</div>
<div class="flex items-center gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide snap-x">
<?php foreach ($languages as $lang): ?>
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="flex-shrink-0 flex items-center gap-2 px-4 py-2 rounded-full border border-accents-2 bg-background hover:border-foreground transition-all text-sm font-medium snap-start shadow-sm">
<span class="fi fi-<?= $lang['flag'] ?> rounded-full shadow-sm"></span>
<span class="whitespace-nowrap"><?= $lang['name'] ?></span>
</button>
<?php endforeach; ?>
</div>
<?php if(isset($_SESSION['user_id'])): ?>
<div class="pt-2 border-t border-accents-2">
<a href="/logout" class="flex items-center justify-center gap-2 w-full px-4 py-3 rounded-xl bg-red-500/10 text-red-600 font-bold hover:bg-red-500/20 transition-all">
<i data-lucide="log-out" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
<span>Logout System</span>
</a>
</div>
<?php endif; ?>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,475 @@
<?php
// Determine active link state
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$isDashboard = strpos($uri, '/dashboard') !== false;
$isGenerate = strpos($uri, '/hotspot/generate') !== false;
$isTemplates = strpos($uri, '/settings/templates') !== false;
$isSettings = ($uri === '/settings' || strpos($uri, '/settings/') !== false) && !$isTemplates;
// Hotspot Group Active Check
$hotspotPages = ['/hotspot/users', '/hotspot/profiles', '/hotspot/generate', '/hotspot/cookies'];
$isHotspotActive = false;
foreach ($hotspotPages as $page) {
if (strpos($uri, $page) !== false) {
$isHotspotActive = true;
break;
}
}
// Status Group Active Check
$statusPages = ['/hotspot/active', '/hotspot/hosts'];
$isStatusActive = false;
foreach ($statusPages as $page) {
if (strpos($uri, $page) !== false) {
$isStatusActive = true;
break;
}
}
// Security Group Active Check (Existing)
$securityPages = ['/hotspot/bindings', '/hotspot/walled-garden'];
$isSecurityActive = false;
foreach ($securityPages as $page) {
if (strpos($uri, $page) !== false) {
$isSecurityActive = true;
break;
}
}
// Reports Group Active Check
$reportsPages = ['/reports/resume', '/reports/selling', '/reports/user-log'];
$isReportsActive = false;
foreach ($reportsPages as $page) {
if (strpos($uri, $page) !== false) {
$isReportsActive = true;
break;
}
}
// Network Group Active Check
$networkPages = ['/network/dhcp'];
$isNetworkActive = false;
foreach ($networkPages as $page) {
if (strpos($uri, $page) !== false) {
$isNetworkActive = true;
break;
}
}
// System Group Active Check
$systemPages = ['/system/scheduler'];
$isSystemActive = false;
foreach ($systemPages as $page) {
if (strpos($uri, $page) !== false) {
$isSystemActive = true;
break;
}
}
// Fetch all sessions for the switcher
$configModel = new \App\Models\Config();
$allSessions = $configModel->getAllSessions();
// Find current session details to get Hotspot Name / IP
$currentSessionDetails = [];
foreach ($allSessions as $s) {
if (isset($session) && $s['session_name'] === $session) {
$currentSessionDetails = $s;
break;
}
}
// Determine label: Hotspot Name > IP Address > 'MIVO'
$sessionLabel = $currentSessionDetails['hotspot_name'] ?? $currentSessionDetails['ip_address'] ?? 'MIVO';
if (empty($sessionLabel)) {
$sessionLabel = $currentSessionDetails['ip_address'] ?? 'MIVO';
}
// Helper for Session Initials (Kebab-friendly)
$getInitials = function($name) {
if (empty($name)) return 'UN';
if (strpos($name, '-') !== false) {
$parts = explode('-', $name);
$initials = '';
foreach ($parts as $part) {
if (!empty($part)) $initials .= substr($part, 0, 1);
}
return strtoupper(substr($initials, 0, 2));
}
return strtoupper(substr($name, 0, 2));
};
?>
<div class="flex h-screen overflow-hidden">
<!-- Mobile Sidebar Overlay -->
<div id="sidebar-overlay" class="fixed inset-0 bg-black/50 z-30 hidden md:hidden transition-opacity opacity-0"></div>
<!-- Sidebar -->
<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 -->
<div 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">
<!-- 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">
<img src="/assets/img/logo-m.svg" alt="MIVO Logo" class="h-10 w-auto block dark:hidden">
<img src="/assets/img/logo-m-dark.svg" alt="MIVO Logo" class="h-10 w-auto hidden dark:block">
<span>MIVO</span>
</div>
<!-- Premium Control Pill (Slides in from the Right to replace Brand) -->
<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">
<!-- Language Switcher -->
<div class="relative group/lang">
<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>
</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 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
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
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">
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang-item:scale-110"></span>
<span><?= $lang['name'] ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<div class="pill-divider"></div>
<!-- Theme Toggle (Segmented) -->
<div class="segmented-switch theme-toggle" title="Toggle Theme">
<div class="segmented-switch-slider"></div>
<div class="segmented-switch-btn theme-toggle-light-icon">
<i data-lucide="sun" class="w-4 h-4" stroke-width="3.5"></i>
</div>
<div class="segmented-switch-btn theme-toggle-dark-icon">
<i data-lucide="moon" class="w-4 h-4" stroke-width="3.5"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile Close Button -->
<button id="sidebar-close" class="md:hidden absolute top-4 right-4 text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Sidebar Content -->
<!-- Sidebar Content (RTL for left scrollbar) -->
<div class="flex-1 overflow-y-auto" style="direction: rtl;">
<div class="py-4 px-3 space-y-1" style="direction: ltr;">
<!-- Session Switcher -->
<div class="px-3 mb-6 relative">
<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 -->
<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">
<?= $getInitials($session ?? '') ?>
</div>
<!-- Text Info -->
<div class="flex flex-col text-left min-w-0">
<span class="text-xs font-bold text-accents-6 group-hover:text-foreground transition-colors leading-none truncate"><?= htmlspecialchars($session ?? 'Select Session') ?></span>
<span class="text-[10px] text-accents-4 leading-none mt-1 truncate" title="<?= htmlspecialchars($sessionLabel) ?>">
<?= htmlspecialchars($sessionLabel) ?>
</span>
</div>
<!-- Chevron Icon -->
<div class="h-8 w-8 flex-shrink-0 flex items-center justify-center rounded-lg bg-accents-2/50 group-hover:bg-accents-2 transition-colors">
<i data-lucide="chevrons-up-down" class="!w-4 !h-4 !text-accents-6 dark:!text-accents-6 transition-colors"></i>
</div>
</button>
<!-- 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 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">
Switch Session
</div>
<?php foreach ($allSessions as $s): ?>
<a href="/<?= htmlspecialchars($s['session_name']) ?>/dashboard" class="flex items-center gap-3 px-3 py-2 text-sm hover:bg-accents-1 transition-colors group/item">
<div class="h-6 w-6 rounded flex-shrink-0 bg-accents-2 flex items-center justify-center text-[10px] font-bold">
<?= $getInitials($s['session_name']) ?>
</div>
<div class="flex flex-col overflow-hidden">
<span class="truncate <?= ($session === $s['session_name']) ? 'font-medium text-foreground' : 'text-accents-5 group-hover/item:text-foreground' ?>">
<?= htmlspecialchars($s['session_name']) ?>
</span>
<span class="text-[10px] text-accents-4 truncate">
<?= htmlspecialchars($s['hotspot_name'] ?: $s['ip_address']) ?>
</span>
</div>
<?php if ($session === $s['session_name']): ?>
<i data-lucide="check" class="w-3 h-3 ml-auto text-primary"></i>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<div class="border-t border-accents-2 p-1 bg-accents-1/30">
<a href="/settings/add" class="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accents-2 rounded-md transition-colors text-accents-5 hover:text-foreground">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
<span data-i18n="settings.add_router">Connect Router</span>
</a>
</div>
</div>
</div>
<!-- Dashboard -->
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= $isDashboard ? '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="layout-dashboard" class="w-4 h-4"></i>
<span data-i18n="sidebar.dashboard">Dashboard</span>
</a>
<!-- Quick Print -->
<a href="/<?= htmlspecialchars($session) ?>/quick-print" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= (strpos($uri, '/quick-print') !== false) ? '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="printer" class="w-4 h-4"></i>
<span data-i18n="sidebar.quick_print">Quick Print</span>
</a>
<!-- Hotspots Separator -->
<div class="pt-4 pb-1 px-3">
<div class="text-xs font-semibold text-accents-5 uppercase tracking-wider" data-i18n="sidebar.hotspot">Hotspots</div>
</div>
<!-- Hotspot Group (Collapsible) -->
<div class="space-y-1">
<button type="button" class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-5 hover:text-foreground hover:bg-accents-2/50 group" onclick="toggleMenu('hotspot-menu', this)">
<div class="flex items-center gap-3">
<i data-lucide="wifi" class="w-4 h-4"></i>
<span data-i18n="sidebar.hotspot">Hotspot</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-300 <?= $isHotspotActive ? 'rotate-180' : '' ?>"></i>
</button>
<div id="hotspot-menu" class="space-y-1 pl-9 overflow-hidden transition-[max-height] duration-300 ease-in-out" style="max-height: <?= $isHotspotActive ? '500px' : '0px' ?>">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/users') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.users">Users</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profiles" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/profile') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.profiles">User Profiles</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/generate" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/generate') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.generate">Generate</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/cookies" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/cookies') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.cookies">Cookies</span>
</a>
</div>
</div>
<!-- Status Group (Collapsible) -->
<div class="space-y-1">
<button type="button" class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-5 hover:text-foreground hover:bg-accents-2/50 group" onclick="toggleMenu('status-menu', this)">
<div class="flex items-center gap-3">
<i data-lucide="activity" class="w-4 h-4"></i>
<span data-i18n="sidebar.status">Status</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-300 <?= $isStatusActive ? 'rotate-180' : '' ?>"></i>
</button>
<div id="status-menu" class="space-y-1 pl-9 overflow-hidden transition-[max-height] duration-300 ease-in-out" style="max-height: <?= $isStatusActive ? '500px' : '0px' ?>">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/active" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/active') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.active">Active</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/hosts" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/hosts') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.hosts">Hosts</span>
</a>
</div>
</div>
<!-- Security Group (Collapsible) -->
<div class="space-y-1">
<button type="button" class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-5 hover:text-foreground hover:bg-accents-2/50 group" onclick="toggleMenu('security-menu', this)">
<div class="flex items-center gap-3">
<i data-lucide="shield" class="w-4 h-4"></i>
<span data-i18n="sidebar.security">Security</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-300 <?= $isSecurityActive ? 'rotate-180' : '' ?>"></i>
</button>
<div id="security-menu" class="space-y-1 pl-9 overflow-hidden transition-[max-height] duration-300 ease-in-out" style="max-height: <?= $isSecurityActive ? '500px' : '0px' ?>">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/bindings" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/bindings') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.bindings">IP Bindings</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/walled-garden" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/hotspot/walled-garden') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="hotspot_menu.walled_garden">Walled Garden</span>
</a>
</div>
</div>
<!-- Reports Group -->
<div class="space-y-1">
<button type="button" class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-5 hover:text-foreground hover:bg-accents-2/50 group" onclick="toggleMenu('reports-menu', this)">
<div class="flex items-center gap-3">
<i data-lucide="file-text" class="w-4 h-4"></i>
<span data-i18n="sidebar.reports">Reports</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-300 <?= $isReportsActive ? 'rotate-180' : '' ?>"></i>
</button>
<div id="reports-menu" class="space-y-1 pl-9 overflow-hidden transition-[max-height] duration-300 ease-in-out" style="max-height: <?= $isReportsActive ? '500px' : '0px' ?>">
<a href="/<?= htmlspecialchars($session) ?>/reports/resume" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/reports/resume') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="reports_menu.resume">Resume</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/reports/selling" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/reports/selling') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="reports_menu.selling">Selling Report</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/reports/user-log" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/reports/user-log') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="reports_menu.user_log">User Log</span>
</a>
</div>
</div>
<!-- Network Group -->
<div class="space-y-1">
<button type="button" class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-5 hover:text-foreground hover:bg-accents-2/50 group" onclick="toggleMenu('network-menu', this)">
<div class="flex items-center gap-3">
<i data-lucide="network" class="w-4 h-4"></i>
<span data-i18n="sidebar.network">Network</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-300 <?= $isNetworkActive ? 'rotate-180' : '' ?>"></i>
</button>
<div id="network-menu" class="space-y-1 pl-9 overflow-hidden transition-[max-height] duration-300 ease-in-out" style="max-height: <?= $isNetworkActive ? '500px' : '0px' ?>">
<a href="/<?= htmlspecialchars($session) ?>/network/dhcp" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/network/dhcp') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="network_menu.dhcp">DHCP Leases</span>
</a>
</div>
</div>
<!-- System Group -->
<div class="space-y-1">
<button type="button" class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-5 hover:text-foreground hover:bg-accents-2/50 group" onclick="toggleMenu('system-menu', this)">
<div class="flex items-center gap-3">
<i data-lucide="cpu" class="w-4 h-4"></i>
<span data-i18n="sidebar.system">System</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-300 <?= $isSystemActive ? 'rotate-180' : '' ?>"></i>
</button>
<div id="system-menu" class="space-y-1 pl-9 overflow-hidden transition-[max-height] duration-300 ease-in-out" style="max-height: <?= $isSystemActive ? '500px' : '0px' ?>">
<a href="/<?= htmlspecialchars($session) ?>/system/scheduler" class="block px-3 py-2 rounded-md text-sm transition-colors <?= (strpos($uri, '/system/scheduler') !== false) ? 'bg-white/40 dark:bg-white/5 text-foreground ring-1 ring-white/10 font-medium' : 'text-accents-6 hover:text-foreground' ?>">
<span data-i18n="system_menu.scheduler">Scheduler</span>
</a>
<button onclick="confirmAction('/<?= htmlspecialchars($session) ?>/system/reboot', 'Reboot Router?')" class="w-full text-left block px-3 py-2 rounded-md text-sm text-accents-5 hover:text-red-500 transition-colors">
<span data-i18n="system_menu.reboot">Reboot</span>
</button>
<button onclick="confirmAction('/<?= htmlspecialchars($session) ?>/system/shutdown', 'Shutdown Router?')" class="w-full text-left block px-3 py-2 rounded-md text-sm text-accents-5 hover:text-red-500 transition-colors">
<span data-i18n="system_menu.shutdown">Shutdown</span>
</button>
</div>
</div>
<!-- Systems Separator -->
<div class="pt-4 pb-1 px-3">
<div class="text-xs font-semibold text-accents-5 uppercase tracking-wider" data-i18n="sidebar.system">Systems</div>
</div>
<!-- Settings -->
<a href="/settings" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= $isSettings ? '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="settings" class="w-4 h-4"></i>
<span data-i18n="sidebar.settings">Settings</span>
</a>
<!-- 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' ?>">
<i data-lucide="file-code" class="w-4 h-4"></i>
<span data-i18n="sidebar.templates">Templates</span>
</a>
</div>
<!-- Sidebar Footer -->
<div class="p-4 border-t border-white/10 space-y-3">
<!-- Disconnect (Session) -->
<a href="/" class="group flex items-center justify-between px-3 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 shadow-sm" title="Disconnect Session">
<div class="flex items-center gap-3">
<div class="p-1.5 rounded-lg bg-accents-2/50 group-hover:bg-accents-2 transition-colors">
<i data-lucide="cast" class="!w-4 !h-4 !text-black dark:!text-white !flex-shrink-0 transition-colors"></i>
</div>
<div class="flex flex-col">
<span class="text-xs font-bold text-accents-6 group-hover:text-foreground transition-colors leading-none" data-i18n="sidebar.disconnect">Disconnect</span>
<span class="text-[10px] text-accents-4 leading-none mt-1">Exit Session</span>
</div>
</div>
<i data-lucide="chevron-right" class="!w-4 !h-4 !text-black dark:!text-white !flex-shrink-0 transition-colors"></i>
</a>
<?php if(isset($_SESSION['user_id'])): ?>
<!-- Logout (System) -->
<a href="/logout" class="group flex items-center justify-between px-3 py-2.5 rounded-xl bg-white/50 dark:bg-white/5 border border-accents-2 dark:border-white/10 hover:bg-red-500/10 hover:border-red-500/20 transition-all decoration-0 shadow-sm" title="Logout from Mivo">
<div class="flex items-center gap-3">
<div class="p-1.5 rounded-lg bg-red-500/10 text-red-500 group-hover:bg-red-500/20 transition-colors">
<i data-lucide="log-out" class="w-4 h-4"></i>
</div>
<div class="flex flex-col">
<span class="text-xs font-bold text-accents-6 group-hover:text-red-500 transition-colors leading-none" data-i18n="sidebar.logout">Logout</span>
<span class="text-[10px] text-accents-4 group-hover:text-red-400/80 leading-none mt-1">Sign Out</span>
</div>
</div>
<i data-lucide="chevron-right" class="!w-4 !h-4 !text-black dark:!text-white !flex-shrink-0 group-hover:!text-red-500 transition-colors"></i>
</a>
<?php endif; ?>
</div>
</aside>
<!-- Main Content Wrapper -->
<div class="flex-1 flex flex-col overflow-hidden w-full">
<!-- Mobile Header (Visible only on small screens) -->
<header class="h-16 flex items-center justify-between px-4 border-b border-accents-2 bg-background/80 backdrop-blur-md md:hidden z-20 sticky top-0">
<div class="flex items-center gap-2">
<img src="/assets/img/logo-m.svg" class="h-6 w-auto block dark:hidden">
<img src="/assets/img/logo-m-dark.svg" class="h-6 w-auto hidden dark:block">
<span class="font-bold">MIVO</span>
</div>
<div class="flex items-center gap-4">
<!-- Mobile Premium Control Pill -->
<div class="control-pill scale-90 origin-right transition-transform hover:scale-95">
<!-- Language Switcher -->
<div class="relative group">
<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>
</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 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
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
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">
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
<span><?= $lang['name'] ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<div class="pill-divider"></div>
<!-- Theme Toggle (Segmented) -->
<div class="segmented-switch theme-toggle" title="Toggle Theme">
<div class="segmented-switch-slider"></div>
<div class="segmented-switch-btn theme-toggle-light-icon">
<i data-lucide="sun" class="w-4 h-4" stroke-width="3.5"></i>
</div>
<div class="segmented-switch-btn theme-toggle-dark-icon">
<i data-lucide="moon" class="w-4 h-4" stroke-width="3.5"></i>
</div>
</div>
</div>
<button id="mobile-menu-toggle" class="text-accents-5 hover:text-foreground">
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
</div>
</header>
<!-- Scrollable Page Content -->
<main class="flex-1 overflow-x-hidden overflow-y-auto bg-background p-4 md:p-8">
<div class="max-w-7xl mx-auto">

View File

@@ -0,0 +1,87 @@
<?php
$uri = $_SERVER['REQUEST_URI'];
function isActive($path, $current) {
if ($path === '/settings') {
// Routers is the new home. Active if exactly /settings or /settings/routers
return $current === '/settings' || $current === '/settings/' || strpos($current, '/settings/routers') !== false;
}
return strpos($current, $path) !== false;
}
$menu = [
['label' => 'routers_title', 'url' => '/settings', 'namespace' => 'settings'],
['label' => 'system', 'url' => '/settings/system', 'namespace' => 'settings'],
['label' => 'templates_title', 'url' => '/settings/templates', 'namespace' => 'settings'],
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
];
?>
<nav id="settings-sidebar" class="w-full sticky top-[64px] z-40 bg-background/95 backdrop-blur border-b border-accents-2 transition-all duration-300">
<div class="max-w-7xl mx-auto px-4 md:px-8"> <!-- Aligned with header_main max-w-7xl -->
<div class="relative py-2 flex items-start gap-2">
<!-- Menu Container (Toggles between flex-row/scroll and grid) -->
<div id="sub-navbar-menu" class="flex-1 flex flex-row items-center overflow-x-auto no-scrollbar mask-fade-right gap-2 transition-all duration-300">
<?php foreach($menu as $item):
$active = isActive($item['url'], $uri);
?>
<a href="<?= $item['url'] ?>"
class="sub-nav-item whitespace-nowrap px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 border border-transparent
<?= $active ? 'bg-foreground text-background shadow-sm' : 'text-accents-5 hover:text-foreground hover:bg-accents-1' ?>"
data-i18n="<?= ($item['namespace'] ?? 'settings') . '.' . $item['label'] ?>">
<?= $item['label'] ?>
</a>
<?php endforeach; ?>
</div>
<!-- Toggle Button -->
<button id="sub-navbar-toggle" class="flex-shrink-0 p-2 text-accents-5 hover:text-foreground hover:bg-accents-1 rounded-full transition-colors hidden sm:block" title="Expand Menu">
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-300"></i>
</button>
</div>
</div>
</nav>
<script>
document.addEventListener('DOMContentLoaded', () => {
const toggleBtn = document.getElementById('sub-navbar-toggle');
const menu = document.getElementById('sub-navbar-menu');
const icon = toggleBtn?.querySelector('i');
let isExpanded = false;
if (toggleBtn && menu) {
// Check if content overflows to decide if we even show the toggle initially?
// For now, always show it on sm+ screens if desired, or we can check scrollWidth > clientWidth.
// Let's keep it simple: always available on desktop/tablet to see full grid.
toggleBtn.addEventListener('click', () => {
isExpanded = !isExpanded;
if (isExpanded) {
// Expand: Grid Layout
menu.classList.remove('flex-row', 'overflow-x-auto', 'whitespace-nowrap', 'mask-fade-right', 'items-center');
menu.classList.add('grid', 'grid-cols-2', 'sm:grid-cols-3', 'md:grid-cols-4', 'lg:grid-cols-5', 'gap-2', 'pb-4');
icon.style.transform = 'rotate(180deg)';
} else {
// Collapse: Scroll Layout
menu.classList.add('flex-row', 'overflow-x-auto', 'whitespace-nowrap', 'mask-fade-right', 'items-center');
menu.classList.remove('grid', 'grid-cols-2', 'sm:grid-cols-3', 'md:grid-cols-4', 'lg:grid-cols-5', 'gap-2', 'pb-4');
icon.style.transform = 'rotate(0deg)';
// Reset scroll position to start? or keep?
menu.scrollLeft = 0;
}
});
}
});
// Re-run Lucide mainly for the chevron if this is loaded via PJAX (though sidebar is usually persistent in SPA layout?
// Wait, in PJAX we replace content, not the sidebar if it's outside.
// BUT sidebar_settings.php is INSIDE the view in the current PHP architecture.
// So it gets re-rendered on every navigation if we don't change that.
// The current SPA script replaces `#settings-content-area`.
// We need to move the sidebar OUT of the `#settings-content-area` target in the PHP files if we want it to persist...
// OR we just re-init the script. Since it's inline, it runs on content injection.
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>

80
app/Views/login.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
$title = 'MIVO Login';
include ROOT . '/app/Views/layouts/header_public.php';
?>
<!-- Login Container -->
<main class="flex-grow flex items-center justify-center flex-col w-full">
<div class="w-full max-w-full sm:max-w-md z-10 p-4 sm:p-6 animate-fade-in-up">
<div class="text-center mb-6 sm:mb-10">
<!-- Brand / Logo Area -->
<div class="flex justify-center mb-6 sm:mb-8">
<div class="relative group">
<!-- <div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div> --> <!--SAYA TIDAK SUKA INI -->
<img src="/assets/img/logo-m.svg" alt="MIVO Logo" class="relative h-10 sm:h-12 w-auto block dark:hidden transform transition-transform duration-300 group-hover:scale-105">
<img src="/assets/img/logo-m-dark.svg" alt="MIVO Logo" class="relative h-10 sm:h-12 w-auto hidden dark:block transform transition-transform duration-300 group-hover:scale-105">
</div>
</div>
<p class="text-accents-5 text-sm mb-6 sm:mb-10" data-i18n="login.welcome">Welcome back, please sign in to continue.</p>
</div>
<div class="card p-6 sm:p-8 relative overflow-hidden">
<form action="/login" method="POST" class="space-y-4 relative z-10">
<div class="space-y-2">
<label class="text-xs font-bold text-accents-5 uppercase tracking-wider ml-1" data-i18n="login.username">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
<i data-lucide="user" class="h-4 w-4 text-accents-6"></i>
</div>
<input type="text" name="username" class="form-input pl-10" placeholder="mivo" required autocomplete="username" data-i18n-placeholder="login.username">
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-bold text-accents-5 uppercase tracking-wider ml-1" data-i18n="login.password">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
<i data-lucide="key" class="h-4 w-4 text-accents-6"></i>
</div>
<input type="password" name="password" id="password" class="form-input pl-10 pr-10" placeholder="••••••••" required autocomplete="current-password" data-i18n-placeholder="login.password">
<button type="button" onclick="togglePassword()" class="absolute inset-y-0 right-3 flex items-center text-accents-5 hover:text-foreground focus:outline-none cursor-pointer z-10">
<i id="eye-icon" data-lucide="eye" class="h-4 w-4"></i>
<i id="eye-off-icon" data-lucide="eye-off" class="h-4 w-4 hidden"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-full h-10 shadow-lg hover:shadow-primary/20" data-i18n="login.sign_in">
Sign In
</button>
</form>
</div>
</div>
</main>
<?php include ROOT . '/app/Views/layouts/footer_public.php'; ?>
<script>
// Toggle Password Logic
function togglePassword() {
const pwd = document.getElementById('password');
const eye = document.getElementById('eye-icon');
const eyeOff = document.getElementById('eye-off-icon');
if (pwd.type === 'password') {
pwd.type = 'text';
eye.classList.add('hidden');
eyeOff.classList.remove('hidden');
} else {
pwd.type = 'password';
eye.classList.remove('hidden');
eyeOff.classList.add('hidden');
}
}
</script>

247
app/Views/network/dhcp.php Normal file
View File

@@ -0,0 +1,247 @@
<?php
$title = "DHCP Leases";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Filter Data
$uniqueServers = [];
if (!empty($leases)) {
foreach ($leases as $lease) {
$s = $lease['server'] ?? '';
if(!empty($s)) $uniqueServers[$s] = $s;
}
}
sort($uniqueServers);
?>
<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="dhcp.title">DHCP Leases</h1>
<p class="text-accents-5"><span data-i18n="dhcp.subtitle">Active DHCP leases for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button>
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a>
</div>
</div>
<?php if (isset($error) && $error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" data-i18n-placeholder="common.table.search_placeholder" placeholder="Search mac, ip, host...">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-40">
<select id="filter-server" class="custom-select" data-search="true">
<option value="" data-i18n="dhcp.all_servers">All Servers</option>
<?php foreach($uniqueServers as $s): ?>
<option value="<?= htmlspecialchars($s) ?>"><?= htmlspecialchars($s) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="table-container">
<table class="table-glass" id="dhcp-table">
<thead>
<tr>
<th data-i18n="dhcp.address">Address</th>
<th data-i18n="dhcp.mac">MAC Address</th>
<th data-sort="server" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="dhcp.server">Server</th>
<th data-sort="status" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="dhcp.status">Status</th>
<th data-sort="host" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="dhcp.host">Host Name</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($leases)): ?>
<?php foreach ($leases as $lease):
$status = $lease['status'] ?? 'unknown';
$isBound = $status === 'bound';
$statusClass = $isBound ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-accents-2 text-accents-5';
?>
<tr class="table-row-item group"
data-server="<?= htmlspecialchars($lease['server'] ?? '') ?>"
data-mac="<?= strtolower($lease['mac-address'] ?? '') ?>"
data-address="<?= htmlspecialchars($lease['address'] ?? '') ?>"
data-host="<?= strtolower($lease['host-name'] ?? $lease['comment'] ?? '') ?>"
data-status="<?= $status ?>">
<td>
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($lease['address'] ?? '-') ?></div>
</td>
<td>
<div class="flex items-center">
<i data-lucide="network" class="w-4 h-4 mr-2 text-accents-4"></i>
<span class="font-mono text-sm text-foreground uppercase"><?= htmlspecialchars($lease['mac-address'] ?? '-') ?></span>
</div>
</td>
<td>
<span class="text-sm text-accents-6"><?= htmlspecialchars($lease['server'] ?? '-') ?></span>
</td>
<td>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <?= $statusClass ?>">
<?= ucfirst($status) ?>
</span>
</td>
<td>
<div class="text-sm text-accents-5"><?= htmlspecialchars($lease['host-name'] ?? $lease['comment'] ?? '-') ?></div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5" data-i18n="common.table.showing" data-i18n-params='{"start": "0", "end": "0", "total": "0"}'>
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> leases
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '', server: '' };
// Placeholder translation handled via data-i18n-placeholder
this.init();
}
init() {
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
document.getElementById('filter-server').addEventListener('change', (e) => {
this.filters.server = e.target.value;
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const svr = row.dataset.server || '';
const mac = row.dataset.mac || '';
const addr = row.dataset.address || '';
const host = row.dataset.host || '';
if (this.filters.server && svr !== this.filters.server) return false;
if (this.filters.search) {
if (!mac.includes(this.filters.search) && !addr.includes(this.filters.search) && !host.includes(this.filters.search)) return false;
}
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(s => new CustomSelect(s));
}
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>

144
app/Views/print/custom.php Normal file
View File

@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Print Voucher</title>
<style>
@media print {
.no-print { display: none !important; }
body {
margin: 0;
padding: 0;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
body { margin: 0; padding: 0; background: #eee; font-family: sans-serif; }
.voucher-wrapper {
/* Wrapper for page break control if needed */
display: inline-block;
margin: 5px;
page-break-inside: avoid;
}
</style>
<script src="/assets/js/qrious.min.js"></script>
</head>
<body>
<?php include __DIR__ . '/toolbar.php'; ?>
<div style="padding: 20px; text-align: center;">
<?php foreach($users as $index => $u): ?>
<div class="voucher-wrapper">
<?php
$html = $templateContent;
// Replace Variables
// Standard variables
$replacements = [
'{{username}}' => $u['username'],
'{{password}}' => $u['password'],
'{{price}}' => $u['price'],
'{{validity}}' => $u['validity'],
'{{timelimit}}' => $u['timelimit'] ?? $u['validity'], // Fallback if missing
'{{datalimit}}' => $u['datalimit'] ?? '',
'{{profile}}' => $u['profile'],
'{{comment}}' => $u['comment'],
'{{hotspotname}}' => $u['hotspotname'],
'{{dns_name}}' => $u['dns_name'],
'{{login_url}}' => $u['login_url'],
'{{num}}' => ($index + 1),
'{{logo}}' => '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default Logo placeholder
];
// 1. Handle {{logo id=...}}
$html = preg_replace_callback('/\{\{logo\s+id=[\'"]?([^\'"\s]+)[\'"]?\}\}/i', function($matches) use ($logoMap) {
$id = $matches[1];
if (isset($logoMap[$id])) {
return '<img src="' . $logoMap[$id] . '" style="height:50px; width:auto;">'; // Default style, user can wrap in div
}
return ''; // Return empty if not found
}, $html);
foreach ($replacements as $key => $val) {
$html = str_replace($key, $val, $html);
}
// 2. Handle QR Code with Logo support
$html = preg_replace_callback('/\{\{qrcode(?:\s+(.*?))?\}\}/i', function($matches) use ($index, $u, $logoMap) {
$qrId = "qr-custom-" . $index . "-" . uniqid();
$qrCodeValue = $u['login_url'] . "?user=" . $u['username'] . "&password=" . $u['password'];
// Default Options
$opts = [
'element' => 'document.getElementById("'.$qrId.'")',
'value' => $qrCodeValue,
'size' => 100,
'foreground' => 'black',
'background' => 'white',
'padding' => null,
'logo' => null // Logo ID
];
$rounded = '';
// Parse Attributes
if (!empty($matches[1])) {
$attrs = $matches[1];
if (preg_match('/fg\s*=\s*[\'"]?([^\'"\s]+)[\'"]?/i', $attrs, $m)) $opts['foreground'] = $m[1];
if (preg_match('/bg\s*=\s*[\'"]?([^\'"\s]+)[\'"]?/i', $attrs, $m)) $opts['background'] = $m[1];
if (preg_match('/size\s*=\s*[\'"]?(\d+)[\'"]?/i', $attrs, $m)) $opts['size'] = $m[1];
if (preg_match('/padding\s*=\s*[\'"]?(\d+)[\'"]?/i', $attrs, $m)) $opts['padding'] = $m[1];
if (preg_match('/rounded\s*=\s*[\'"]?(\d+)[\'"]?/i', $attrs, $m)) $rounded = 'border-radius: ' . $m[1] . 'px;';
if (preg_match('/logo\s*=\s*[\'"]?([^\'"\s]+)[\'"]?/i', $attrs, $m)) $opts['logo'] = $m[1];
}
// CSS Styles
$cssPadding = $opts['padding'] ? ('padding: ' . $opts['padding'] . 'px; ') : '';
$cssBg = 'background-color: ' . $opts['background'] . '; ';
$baseStyle = 'display: inline-block; vertical-align: middle; ' . $cssBg . $cssPadding . $rounded;
// JS Generation
$qrJs = "
(function() {
var qr = new QRious({
element: document.getElementById('$qrId'),
value: \"{$opts['value']}\",
size: {$opts['size']},
foreground: \"{$opts['foreground']}\",
backgroundAlpha: 0
});
";
// If Logo is requested and found
if ($opts['logo'] && isset($logoMap[$opts['logo']])) {
$logoPath = $logoMap[$opts['logo']];
$qrJs .= "
var img = new Image();
img.src = '$logoPath';
img.onload = function() {
var canvas = document.getElementById('$qrId');
var ctx = canvas.getContext('2d');
var size = {$opts['size']};
var logoSize = size * 0.2; // Logo is 20% of QR size
var logoPos = (size - logoSize) / 2;
ctx.drawImage(img, logoPos, logoPos, logoSize, logoSize);
};
";
}
$qrJs .= "})();";
return '<canvas id="'.$qrId.'" style="'.$baseStyle.'"></canvas><script>'.$qrJs.'</script>';
}, $html);
echo $html;
?>
</div>
<?php endforeach; ?>
</div>
</body>
</html>

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Print Voucher</title>
<style>
@media print {
.no-print { display: none !important; }
body { margin: 0; padding: 0; }
}
body { font-family: 'Courier New', Courier, monospace; background: #eee; }
.voucher-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.voucher {
width: 300px;
background: #fff;
padding: 10px;
margin: 5px;
border: 1px solid #ccc;
page-break-inside: avoid;
display: inline-block;
}
.header { text-align: center; font-weight: bold; margin-bottom: 5px; }
.row { display: flex; justify-content: space-between; margin-bottom: 2px; font-size: 12px; }
.code { font-size: 16px; font-weight: bold; text-align: center; margin: 10px 0; border: 1px dashed #000; padding: 5px; }
.qr-wrapper { text-align: center; margin-top: 5px; }
</style>
<script src="/assets/js/qrious.min.js"></script>
</head>
<body>
<?php include __DIR__ . '/toolbar.php'; ?>
<div class="voucher-container">
<?php foreach($users as $index => $u): ?>
<div class="voucher">
<div class="header"><?= htmlspecialchars($u['dns_name']) ?></div>
<div class="row"><span>Profile:</span> <span><?= htmlspecialchars($u['profile']) ?></span></div>
<div class="row"><span>Valid:</span> <span><?= htmlspecialchars($u['validity']) ?></span></div>
<div class="row"><span>Price:</span> <span><?= htmlspecialchars($u['price']) ?></span></div>
<div class="code">
User: <?= htmlspecialchars($u['username']) ?><br>
Pass: <?= htmlspecialchars($u['password']) ?>
</div>
<div class="qr-wrapper">
<canvas id="qr-<?= $index ?>"></canvas>
</div>
<div style="text-align:center; font-size: 10px; margin-top:5px;">
Login: <?= htmlspecialchars($u['login_url']) ?>
</div>
</div>
<script>
(function() {
new QRious({
element: document.getElementById('qr-<?= $index ?>'),
value: '<?= htmlspecialchars($u['login_url']) ?>?user=<?= htmlspecialchars($u['username']) ?>&password=<?= htmlspecialchars($u['password']) ?>',
size: 100
});
})();
</script>
<?php endforeach; ?>
</div>
<script>
// Optional: Auto print if directed
// window.print();
</script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
<?php
// Print Toolbar (No Print)
// Expected variables: $templates (array), $currentTemplate (id or 'default'), $session (string)
// Also preserves current query params (like ids=...)
$currentQuery = $_GET;
unset($currentQuery['template']); // Remove old template param
$queryString = http_build_query($currentQuery);
$baseUrl = strtok($_SERVER["REQUEST_URI"], '?');
// If ids is missing (e.g. Quick Print single ID in segment), we don't need to append it if it's not in GET.
// Logic: Reload current URL but with new template param.
?>
<div class="no-print" style="position: sticky; top:0; background: #fff; border-bottom: 1px solid #ddd; padding: 10px; z-index: 50; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="display: flex; align-items: center; gap: 10px;">
<label style="font-size: 14px; font-weight: bold; color: #333;">Template:</label>
<select onchange="changeTemplate(this.value)" style="padding: 5px; border: 1px solid #ccc; rounded: 4px;">
<option value="default" <?= $currentTemplate === 'default' ? 'selected' : '' ?>>Default Thermal</option>
<?php if (!empty($templates)): ?>
<?php foreach ($templates as $t): ?>
<option value="<?= $t['id'] ?>" <?= (string)$currentTemplate === (string)$t['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($t['name']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div>
<button onclick="window.print()" style="padding: 6px 16px; background: #0070f3; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer;">Print</button>
<button onclick="window.close()" style="padding: 6px 16px; background: #eee; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; margin-left: 5px;">Close</button>
</div>
</div>
<script>
function changeTemplate(val) {
const currentUrl = new URL(window.location.href);
if (val === 'default') {
currentUrl.searchParams.delete('template');
} else {
currentUrl.searchParams.set('template', val);
}
window.location.href = currentUrl.toString();
}
</script>

215
app/Views/public/status.php Normal file
View File

@@ -0,0 +1,215 @@
<?php
$title = 'Check Voucher Status';
include ROOT . '/app/Views/layouts/header_public.php';
?>
<!-- Main Container -->
<main class="flex-grow flex items-center justify-center w-full">
<div class="w-full max-w-lg z-10 p-4 md:p-6 animate-fade-in-up">
<div class="flex flex-col space-y-8 text-center">
<!-- Brand -->
<div class="flex justify-center">
<div class="relative group">
<img src="/assets/img/logo-m.svg" alt="MIVO Logo" class="relative h-12 w-auto block dark:hidden transform transition-transform duration-300 group-hover:scale-105">
<img src="/assets/img/logo-m-dark.svg" alt="MIVO Logo" class="relative h-12 w-auto hidden dark:block transform transition-transform duration-300 group-hover:scale-105">
</div>
</div>
<!-- Text -->
<div>
<h1 class="text-2xl md:text-3xl font-extrabold tracking-tight mb-3 text-foreground" data-i18n="status.check_title">Check Voucher Status</h1>
<p class="text-accents-5 text-sm md:text-base leading-relaxed max-w-sm mx-auto" data-i18n="status.check_desc">
Monitor your data usage and voucher validity in real-time without needing to re-login.
</p>
</div>
<!-- Check Form -->
<div class="card p-6 sm:p-8 relative overflow-hidden w-full text-left">
<form onsubmit="checkStatus(event)" class="relative z-10">
<div class="space-y-4">
<div>
<label class="text-xs font-semibold text-accents-5 uppercase tracking-wider ml-1 mb-1 block" data-i18n="status.voucher_code_label">Voucher Code</label>
<div class="relative">
<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>
</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">
</div>
</div>
<button type="submit" id="chk-btn" class="w-full btn btn-primary h-11 text-base font-bold shadow-lg hover:shadow-primary/20">
<span id="btn-text" data-i18n="status.check_now">Check Now</span>
<i id="btn-loader" data-lucide="loader-2" class="w-4 h-4 animate-spin hidden"></i>
</button>
</div>
</form>
</div>
</div>
</div>
</main>
<?php include ROOT . '/app/Views/layouts/footer_public.php'; ?>
<!-- Logic 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) {
e.preventDefault();
const code = document.getElementById('voucher-code').value.trim();
if (!code) return;
const btn = document.getElementById('chk-btn');
const btnText = document.getElementById('btn-text');
const loader = document.getElementById('btn-loader');
// Set Loading
btn.disabled = true;
btnText.classList.add('hidden');
loader.classList.remove('hidden');
try {
const pathParts = window.location.pathname.split('/');
const session = pathParts[1];
const response = await fetch('/api/status/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: session, code: code })
});
const json = await response.json();
if (json.success) {
const d = json.data;
// Build HTML for SweetAlert
// Status Badge Logic
// Status Badge Logic (Glassmorphism)
let statusColor = 'bg-blue-500/10 text-blue-600 border-blue-200 dark:border-blue-800 dark:text-blue-400';
if (d.status === 'active') statusColor = 'bg-emerald-500/10 text-emerald-600 border-emerald-200 dark:border-emerald-800 dark:text-emerald-400';
if (d.status === 'expired') statusColor = 'bg-slate-500/10 text-slate-600 border-slate-200 dark:border-slate-800 dark:text-slate-400';
if (d.status === 'limited') statusColor = 'bg-orange-500/10 text-orange-600 border-orange-200 dark:border-orange-800 dark:text-orange-400';
if (d.status === 'locked') statusColor = 'bg-red-500/10 text-red-600 border-red-200 dark:border-red-800 dark:text-red-400';
const htmlContent = `
<div class="text-left mt-6 relative overflow-hidden rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md shadow-2xl ring-1 ring-black/5 dark:ring-white/5">
<!-- Background Decoration -->
<div class="absolute -top-10 -right-10 w-32 h-32 bg-blue-500/20 rounded-full blur-3xl pointer-events-none"></div>
<div class="absolute -bottom-10 -left-10 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl pointer-events-none"></div>
<!-- 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>
<span class="text-[10px] text-accents-5 font-bold uppercase tracking-widest block mb-0.5">Voucher Code</span>
<span class="font-mono text-xl md:text-2xl font-black tracking-tighter text-foreground">${d.username}</span>
</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">
${d.status}
</div>
</div>
<!-- Data Usage Bar -->
<div class="relative p-5 md:p-6 pb-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-lg font-black text-blue-600 dark:text-blue-400 font-mono tracking-tight">${d.data_left}</span>
</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="h-full bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg relative overflow-hidden" style="width: 100%">
<div class="absolute inset-0 bg-white/20 animate-[shimmer_2s_infinite]"></div>
</div>
</div>
<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>
</div>
</div>
<!-- Details Table -->
<div class="p-5 md:p-6 pt-2">
<table class="w-full text-sm text-left">
<tbody class="divide-y divide-white/10">
<tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Package</td>
<td class="py-3 text-right font-bold text-foreground font-mono">${d.profile}</td>
</tr>
<tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Validity</td>
<td class="py-3 text-right font-bold text-foreground font-mono">${d.validity}</td>
</tr>
<tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Uptime</td>
<td class="py-3 text-right font-medium text-foreground font-mono">${d.uptime_used}</td>
</tr>
<tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Expires</td>
<td class="py-3 text-right font-medium text-foreground font-mono">${d.expiration}</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
Swal.fire({
title: 'Voucher Details',
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 {
Swal.fire({
icon: 'error',
title: 'Voucher Not Found',
text: json.message || "The voucher code you entered does not exist.",
confirmButtonText: 'Try Again',
customClass: {
popup: 'swal2-premium-card',
confirmButton: 'btn btn-primary',
},
buttonsStyling: false,
didClose: () => {
setTimeout(() => {
const el = document.getElementById('voucher-code');
if(el) { el.focus(); el.select(); }
}, 100);
}
});
}
} catch (err) {
console.error(err);
Swal.fire({
icon: 'error',
title: 'System Error',
text: 'Unable to connect to the server.',
confirmButtonText: 'Close',
customClass: {
popup: 'swal2-premium-card',
confirmButton: 'btn btn-secondary',
},
buttonsStyling: false
});
} finally {
btn.disabled = false;
btnText.classList.remove('hidden');
loader.classList.add('hidden');
}
}
</script>

View File

@@ -0,0 +1,106 @@
<?php
// Quick Print Dashboard (Card View)
$title = 'Quick Print';
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight text-foreground" data-i18n="quick_print.title">Quick Print</h1>
<p class="text-accents-5" data-i18n="quick_print.subtitle">Instant voucher generation and printing.</p>
</div>
<div class="flex items-center gap-3">
<a href="/<?= htmlspecialchars($session) ?>/quick-print/manage" class="hidden sm:flex items-center gap-2 btn btn-secondary">
<i data-lucide="settings" class="w-4 h-4"></i>
<span data-i18n="quick_print.manage">Manage Packages</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/quick-print/manage" class="sm:hidden btn btn-secondary px-2">
<i data-lucide="settings" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Cards Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<?php if (empty($packages)): ?>
<div class="col-span-full flex flex-col items-center justify-center p-12 border-2 border-dashed border-accents-2 rounded-lg text-accents-5">
<i data-lucide="printer" class="w-12 h-12 mb-4 opacity-50"></i>
<p class="text-lg font-medium" data-i18n="quick_print.no_packages">No Packages Found</p>
<p class="text-sm mb-6" data-i18n="quick_print.create_first">Create a Quick Print package to get started.</p>
<a href="/<?= htmlspecialchars($session) ?>/quick-print/manage" class="btn btn-primary" data-i18n="quick_print.create_package">
Create Package
</a>
</div>
<?php else: ?>
<?php foreach ($packages as $pkg): ?>
<!-- Card -->
<div class="card relative group overflow-hidden hover:border-primary/50 hover:-translate-y-1 transition-all duration-300 p-0">
<!-- Color Header -->
<div class="h-2 <?= htmlspecialchars($pkg['color'] ?? 'bg-blue-500') ?>"></div>
<div class="p-5 bg-background">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="font-bold text-lg text-foreground truncate" title="<?= htmlspecialchars($pkg['name']) ?>">
<?= htmlspecialchars($pkg['name']) ?>
</h3>
<div class="text-xs text-accents-5 font-mono mt-1">
<span data-i18n="quick_print.profile">Profile</span>: <?= htmlspecialchars($pkg['profile']) ?>
</div>
</div>
<div class="text-right">
<div class="font-bold text-foreground">
<?= htmlspecialchars($pkg['price'] > 0 ? number_format($pkg['price'], 0, ',', '.') : 'Free') ?>
</div>
<div class="text-xs text-accents-5" data-i18n="<?= $pkg['time_limit'] ?: 'common.unlimited' ?>">
<?= htmlspecialchars($pkg['time_limit'] ?: 'Unlimited') ?>
</div>
</div>
</div>
<!-- Details -->
<div class="space-y-2 text-sm text-accents-5 mb-6">
<div class="flex justify-between border-b border-accents-1 pb-1">
<span data-i18n="quick_print.prefix">Prefix</span>
<span class="font-mono text-xs"><?= htmlspecialchars($pkg['prefix']) ?: '-' ?></span>
</div>
<div class="flex justify-between border-b border-accents-1 pb-1">
<span data-i18n="quick_print.server">Server</span>
<span><?= htmlspecialchars($pkg['server']) ?></span>
</div>
</div>
<!-- Action -->
<button onclick="printPackage('<?= $pkg['id'] ?>', '<?= htmlspecialchars($pkg['name']) ?>')" class="w-full btn btn-primary flex items-center justify-center gap-2">
<i data-lucide="printer" class="w-4 h-4"></i>
<span data-i18n="quick_print.print_voucher">Print Voucher</span>
</button>
<?php if(!empty($pkg['comment'])): ?>
<p class="text-xs text-accents-4 text-center mt-3 truncate"><?= htmlspecialchars($pkg['comment']) ?></p>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Print Script -->
<script>
function printPackage(id, name) {
// Open print window
const width = 400;
const height = 600;
const left = (window.innerWidth - width) / 2;
const top = (window.innerHeight - height) / 2;
const url = `/<?= htmlspecialchars($session) ?>/quick-print/print/${id}`;
window.open(url, `Print_${name}`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
}
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,329 @@
<?php
// Quick Print Management (List & CRUD)
$title = 'Manage Quick Print';
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-foreground" data-i18n="quick_print.manage_title">Manage Packages</h1>
<p class="text-accents-5"><span data-i18n="quick_print.manage_subtitle">Configure your Quick Print voucher packages for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex items-center gap-2">
<a href="/<?= htmlspecialchars($session) ?>/quick-print" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2 inline-block"></i> <span data-i18n="common.back">Back</span>
</a>
<button onclick="openModal('add')" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
<span data-i18n="quick_print.add_package">Add Package</span>
</button>
</div>
</div>
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search package name..." data-i18n-placeholder="common.table.search_placeholder">
</div>
</div>
<!-- Table -->
<div class="table-container">
<table class="table-glass" id="packages-table">
<thead>
<tr>
<th data-sort="name" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="quick_print.name">Name</th>
<th data-i18n="quick_print.profile">Profile</th>
<th data-i18n="quick_print.prefix">Prefix</th>
<th data-sort="price" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="quick_print.price">Price</th>
<th data-i18n="quick_print.time_limit">Time Limit</th>
<th class="text-right" data-i18n="common.actions">Actions</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (empty($packages)): ?>
<tr>
<td colspan="6" class="p-8 text-center text-accents-5" data-i18n="quick_print.no_packages_found">No packages found.</td>
</tr>
<?php else: ?>
<?php foreach ($packages as $pkg): ?>
<tr class="table-row-item group"
data-name="<?= strtolower($pkg['name']) ?>"
data-price="<?= $pkg['price'] ?>">
<td class="font-medium text-foreground">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full <?= htmlspecialchars($pkg['color']) ?>"></div>
<?= htmlspecialchars($pkg['name']) ?>
</div>
</td>
<td><?= htmlspecialchars($pkg['profile']) ?></td>
<td class="font-mono text-xs"><?= htmlspecialchars($pkg['prefix']) ?: '-' ?></td>
<td><?= htmlspecialchars($pkg['price'] > 0 ? number_format($pkg['price'], 0, ',', '.') : 'Free') ?></td>
<td><?= htmlspecialchars($pkg['time_limit'] ?: 'Unlimited') ?></td>
<td class="text-right text-sm">
<div class="flex items-center justify-end gap-2 table-actions-reveal">
<!-- Simple Delete Form -->
<form action="/<?= htmlspecialchars($session) ?>/quick-print/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('quick_print.delete_package') : 'Delete Package?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete this Quick Print package?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= htmlspecialchars($pkg['id']) ?>">
<button type="submit" class="btn-icon-danger" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
<button type="button" class="btn-icon" title="Edit">
<i data-lucide="edit-3" class="w-4 h-4"></i>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> packages
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<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">
<div id="modal-content" class="card w-full max-w-lg mx-4 transform scale-95 transition-transform duration-200 overflow-hidden p-0">
<div class="flex items-center justify-between px-6 py-4 border-b border-accents-2 bg-accents-1/30">
<h3 class="text-lg font-bold text-foreground" id="modal-title" data-i18n="quick_print.add_package">Add Package</h3>
<button onclick="closeModal()" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="p-6 space-y-4">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<!-- 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>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '' };
this.init();
}
init() {
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const name = row.dataset.name || '';
if (this.filters.search && !name.includes(this.filters.search)) return false;
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
const overlay = document.getElementById('modal-overlay');
const content = document.getElementById('modal-content');
function openModal(mode) {
overlay.classList.remove('hidden');
// Trigger reflow
void overlay.offsetWidth;
overlay.classList.remove('opacity-0');
content.classList.add('open');
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeModal() {
overlay.classList.add('opacity-0');
content.classList.remove('open');
setTimeout(() => {
overlay.classList.add('hidden');
}, 300);
}
document.addEventListener('DOMContentLoaded', () => {
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,113 @@
<?php
$title = "Resume Report";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="reports.resume_title">Resume Report</h1>
<p class="text-accents-5" data-i18n="reports.resume_subtitle">Overview of aggregated income.</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium bg-accents-1 px-3 py-1 rounded-full border border-accents-2">
<span data-i18n="reports.total_income">Total Income</span>: <?= $currency ?> <?= number_format($totalIncome, 0, ',', '.') ?>
</span>
</div>
</div>
<!-- Tabs -->
<div class="mb-6 border-b border-accents-2">
<nav class="flex space-x-4 overflow-x-auto no-scrollbar" aria-label="Tabs">
<button onclick="switchTab('daily')" id="tab-daily" class="px-3 py-2 text-sm font-medium border-b-2 border-primary text-primary active-tab whitespace-nowrap" data-i18n="reports.daily">Daily</button>
<button onclick="switchTab('monthly')" id="tab-monthly" class="px-3 py-2 text-sm font-medium border-b-2 border-transparent text-accents-5 hover:text-foreground whitespace-nowrap" data-i18n="reports.monthly">Monthly</button>
<button onclick="switchTab('yearly')" id="tab-yearly" class="px-3 py-2 text-sm font-medium border-b-2 border-transparent text-accents-5 hover:text-foreground whitespace-nowrap" data-i18n="reports.yearly">Yearly</button>
</nav>
</div>
<!-- Daily Tab -->
<div id="content-daily" class="tab-content">
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th data-i18n="reports.date">Date</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($daily as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Monthly Tab -->
<div id="content-monthly" class="tab-content hidden">
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th data-i18n="reports.month">Month</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($monthly as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Yearly Tab -->
<div id="content-yearly" class="tab-content hidden">
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th data-i18n="reports.year">Year</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($yearly as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script>
function switchTab(tabName) {
// Hide all contents
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
// Show selected
document.getElementById('content-' + tabName).classList.remove('hidden');
// Reset tab styles
document.querySelectorAll('nav button').forEach(el => {
el.classList.remove('border-primary', 'text-primary');
el.classList.add('border-transparent', 'text-accents-5');
});
// Active tab style
const btn = document.getElementById('tab-' + tabName);
btn.classList.remove('border-transparent', 'text-accents-5');
btn.classList.add('border-primary', 'text-primary');
}
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,205 @@
<?php
$title = "Selling Report";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="reports.selling_title">Selling Report</h1>
<p class="text-accents-5"><span data-i18n="reports.selling_subtitle">Sales summary and details for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button>
<button onclick="window.print()" class="btn btn-primary">
<i data-lucide="printer" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.print_report">Print Report</span>
</button>
</div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="card bg-accents-1 border-accents-2">
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_income">Total Income</div>
<div class="text-3xl font-bold text-green-500 mt-2">
<?= \App\Helpers\FormatHelper::formatCurrency($totalIncome, $currency) ?>
</div>
</div>
<div class="card bg-accents-1 border-accents-2">
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_vouchers">Total Vouchers Sold</div>
<div class="text-3xl font-bold text-blue-500 mt-2">
<?= number_format($totalVouchers, 0, ',', '.') ?>
</div>
</div>
</div>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center no-print">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search date..." data-i18n-placeholder="common.table.search_placeholder">
</div>
</div>
<!-- Detailed Table -->
<div class="table-container">
<table class="table-glass" id="report-table">
<thead>
<tr>
<th data-sort="date" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
<th class="text-right" data-i18n="reports.qty">Qty</th>
<th data-sort="total" class="sortable text-right cursor-pointer hover:text-foreground select-none" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (empty($report)): ?>
<tr>
<td colspan="3" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
</tr>
<?php else: ?>
<?php foreach ($report as $row): ?>
<tr class="table-row-item"
data-date="<?= strtolower($row['date']) ?>"
data-total="<?= $row['total'] ?>">
<td class="font-medium"><?= htmlspecialchars($row['date']) ?></td>
<td class="text-right font-mono"><?= number_format($row['count']) ?></td>
<td class="text-right font-mono font-bold text-foreground">
<?= \App\Helpers\FormatHelper::formatCurrency($row['total'], $currency) ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between no-print" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> rows
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<script>
class TableManager {
constructor(rows, itemsPerPage = 15) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '' };
this.init();
}
init() {
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const date = row.dataset.date || '';
if (this.filters.search && !date.includes(this.filters.search)) return false;
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
new TableManager(document.querySelectorAll('.table-row-item'), 15);
});
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,246 @@
<?php
$title = "User Log";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Prepare unique topics for filter
$uniqueTopics = [];
if (!empty($logs) && is_array($logs)) {
foreach ($logs as $log) {
$t = $log['topics'] ?? '';
// Split comma separated topics if needed, but usually it's one string or comma sep string
// Simple approach: Use full string or main topic
if (!empty($t)) $uniqueTopics[$t] = $t;
}
}
sort($uniqueTopics);
?>
<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="reports.user_log_title">User Log</h1>
<p class="text-accents-5"><span data-i18n="reports.user_log_subtitle">Login and logout history for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button>
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a>
</div>
</div>
<?php if (isset($error) && $error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search message..." data-i18n-placeholder="common.table.search_placeholder">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-48">
<select id="filter-topic" class="custom-select" data-search="true">
<option value="" data-i18n="common.all_topics">All Topics</option>
<option value="hotspot,info,debug">hotspot,info,debug</option>
<option value="hotspot,account,info,debug">hotspot,account,info,debug</option>
<option value="system,info,account">system,info,account</option>
<!-- Fallback to generated if diverse -->
<?php foreach($uniqueTopics as $t): ?>
<option value="<?= htmlspecialchars($t) ?>"><?= htmlspecialchars($t) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="table-container">
<table class="table-glass" id="log-table">
<thead>
<tr>
<th class="w-40" data-i18n="reports.time">Time</th>
<th data-sort="topics" class="sortable cursor-pointer hover:text-foreground select-none w-48" data-i18n="reports.topics">Topics</th>
<th data-sort="message" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="reports.message">Message</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($logs) && is_array($logs)): ?>
<?php foreach ($logs as $log):
$topics = $log['topics'] ?? '';
$isError = strpos($topics, 'error') !== false;
$rowClass = $isError ? 'text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10' : '';
?>
<tr class="table-row-item <?= $rowClass ?>"
data-topics="<?= htmlspecialchars($topics) ?>"
data-message="<?= strtolower($log['message'] ?? '') ?>">
<td class="font-mono text-sm text-accents-5">
<?= htmlspecialchars($log['time'] ?? '-') ?>
</td>
<td>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-accents-2 text-accents-6 border border-accents-3">
<?= htmlspecialchars($topics) ?>
</span>
</td>
<td class="text-sm whitespace-normal break-words">
<?= htmlspecialchars($log['message'] ?? '-') ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> logs
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 15) {
this.allRows = Array.from(rows);
// Hide duplicates in unique topics select options (hacky fix for double output)
const seen = new Set();
document.querySelectorAll('#filter-topic option').forEach(o => {
if(seen.has(o.value) || o.value === '') {
if(o.value !== '') o.remove();
} else seen.add(o.value);
});
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '', topics: '' };
this.init();
}
init() {
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
document.getElementById('filter-topic').addEventListener('change', (e) => {
this.filters.topics = e.target.value;
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const topics = row.dataset.topics || '';
const msg = row.dataset.message || '';
if (this.filters.topics && !topics.includes(this.filters.topics)) return false;
if (this.filters.search && !msg.includes(this.filters.search)) return false;
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(s => new CustomSelect(s));
}
new TableManager(document.querySelectorAll('.table-row-item'), 15);
});
</script>

View File

@@ -0,0 +1,340 @@
<?php
$title = "IP Bindings";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Filter Data
$uniqueTypes = [];
if (!empty($items)) {
foreach ($items as $item) {
$t = $item['type'] ?? 'regular';
$uniqueTypes[$t] = $t;
}
}
sort($uniqueTypes);
?>
<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="security.bindings.title">IP Bindings</h1>
<p class="text-accents-5" data-i18n="security.bindings.subtitle" data-i18n-params='{"name": "<?= htmlspecialchars($session) ?>"}'>Manage IP bindings (bypass/blocked) for: <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary" data-i18n="common.dashboard">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> Dashboard
</a>
</div>
</div>
<?php if ($error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center shadow-sm">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<!-- List (2/3) -->
<div class="lg:col-span-2 space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center bg-card p-4 rounded-lg border border-accents-2 shadow-sm">
<!-- Search -->
<div class="relative w-full">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search mac, address, comment..." data-i18n="common.table.search_placeholder">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-40">
<select id="filter-type" class="custom-select" data-search="true">
<option value="" data-i18n="security.bindings.all_types">All Types</option>
<option value="regular" data-i18n="security.bindings.regular">Regular</option>
<option value="bypassed" data-i18n="security.bindings.bypassed">Bypassed</option>
<option value="blocked" data-i18n="security.bindings.blocked">Blocked</option>
</select>
</div>
</div>
</div>
<div class="table-container">
<table class="table-glass" id="bindings-table">
<thead>
<tr>
<th data-i18n="security.bindings.table.mac">MAC Address</th>
<th data-i18n="security.bindings.table.address">Address</th>
<th data-i18n="security.bindings.table.to_address">To Address</th>
<th data-sort="type" class="sortable cursor-pointer hover:text-primary select-none group">
<div class="flex items-center gap-1"><span data-i18n="security.bindings.table.type">Type</span> <i data-lucide="arrow-up-down" class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity"></i></div>
</th>
<th data-sort="comment" class="sortable cursor-pointer hover:text-primary select-none group">
<div class="flex items-center gap-1"><span data-i18n="security.bindings.table.comment">Comment</span> <i data-lucide="arrow-up-down" class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity"></i></div>
</th>
<th class="relative text-right" data-i18n="common.actions">Actions</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($items)): ?>
<?php foreach ($items as $item): ?>
<tr class="table-row-item"
data-type="<?= htmlspecialchars($item['type'] ?? 'regular') ?>"
data-mac="<?= strtolower($item['mac-address'] ?? '') ?>"
data-address="<?= htmlspecialchars($item['address'] ?? '') ?>"
data-comment="<?= strtolower($item['comment'] ?? '') ?>">
<td>
<div class="flex items-center">
<div class="p-1.5 bg-accents-2 rounded mr-2 text-accents-6">
<i data-lucide="link" class="w-3.5 h-3.5"></i>
</div>
<span class="font-mono text-sm text-foreground font-medium"><?= htmlspecialchars($item['mac-address'] ?? '-') ?></span>
</div>
</td>
<td><span class="text-sm text-foreground"><?= htmlspecialchars($item['address'] ?? '-') ?></span></td>
<td><span class="text-sm text-foreground"><?= htmlspecialchars($item['to-address'] ?? '-') ?></span></td>
<td>
<?php
$typeClass = 'bg-accents-2 text-accents-6 border border-accents-3';
if (($item['type']??'') == 'bypassed') $typeClass = 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800';
if (($item['type']??'') == 'blocked') $typeClass = 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800';
?>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <?= $typeClass ?>">
<?= htmlspecialchars($item['type'] ?? 'regular') ?>
</span>
</td>
<td class="text-sm text-accents-5 italic"><?= htmlspecialchars($item['comment'] ?? '-') ?></td>
<td class="text-right text-sm font-medium">
<div class="flex justify-end">
<form action="/<?= htmlspecialchars($session) ?>/hotspot/bindings/remove" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Remove Binding?', 'Are you sure you want to remove the binding for <?= htmlspecialchars($item['mac-address'] ?? '') ?>?', 'Remove', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $item['.id'] ?>">
<button type="submit" class="btn btn-icon-sm hover:bg-red-50 text-accents-5 hover:text-red-600 transition-colors" title="Remove">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span>
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<!-- Add Form (Sticky Side) -->
<div class="lg:col-span-1">
<div class="card p-0 border-accents-2 shadow-lg sticky top-6">
<div class="p-4 border-b border-accents-2 bg-primary/5 flex items-center gap-2">
<div class="p-1.5 bg-primary/10 rounded text-primary">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
</div>
<h3 class="font-bold text-sm uppercase tracking-wide text-primary" data-i18n="security.bindings.form.add_title">Add Binding</h3>
</div>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/bindings/store" method="POST" class="p-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-1 gap-4">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase"><span data-i18n="security.bindings.form.mac_address">MAC Address</span> <span class="text-red-500">*</span></label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-4 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="cpu" class="w-4 h-4"></i>
</span>
<input type="text" name="mac" required class="form-input pl-10" placeholder="00:00:00:00:00:00">
</div>
<p class="text-xs text-accents-5" data-i18n="security.bindings.form.mac_help">Target device MAC address.</p>
</div>
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.bindings.form.address">Address (IP)</label>
<input type="text" name="address" class="form-input" placeholder="192.168.x.x">
<p class="text-xs text-accents-5" data-i18n="security.bindings.form.address_help">Target IP address (optional).</p>
</div>
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.bindings.form.to_address">To Address</label>
<input type="text" name="to_address" class="form-input" placeholder="192.168.x.x">
<p class="text-xs text-accents-5" data-i18n="security.bindings.form.to_address_help">Translate to this IP (optional).</p>
</div>
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.bindings.form.type">Type</label>
<select name="type" class="custom-select w-full">
<option value="regular" data-i18n="security.bindings.regular">Regular</option>
<option value="bypassed" data-i18n="security.bindings.bypassed">Bypassed</option>
<option value="blocked" data-i18n="security.bindings.blocked">Blocked</option>
</select>
</div>
<div class="space-y-1.5 md:col-span-2 lg:col-span-1">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.bindings.form.server">Server</label>
<select name="server" class="custom-select w-full" data-search="true">
<option value="all">all</option>
<!-- Ideally fetch servers -->
</select>
<p class="text-xs text-accents-5" data-i18n="security.bindings.form.server_help">Apply to specific Hotspot server.</p>
</div>
<div class="space-y-1.5 md:col-span-2 lg:col-span-1">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.bindings.form.comment">Comment</label>
<input type="text" name="comment" class="form-input" placeholder="Optional notes" data-i18n-placeholder="security.bindings.form.comment_help">
<p class="text-xs text-accents-5" data-i18n="security.bindings.form.comment_help">Note for this binding.</p>
</div>
<div class="pt-2 md:col-span-2 lg:col-span-1">
<button type="submit" class="btn btn-primary w-full 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="security.bindings.form.save">Save & Bind</span>
</button>
</div>
<!-- Quick Tips -->
<div class="pt-4 mt-4 border-t border-accents-2 md:col-span-2 lg:col-span-1">
<h4 class="text-xs font-bold text-accents-5 uppercase mb-2 flex items-center gap-1">
<i data-lucide="lightbulb" class="w-3 h-3 text-yellow-500"></i> <span data-i18n="common.tips">Tips</span>
</h4>
<ul class="text-xs text-accents-5 space-y-1.5 list-disc list-inside">
<li data-i18n="security.bindings.form.tip_bypassed"><strong>Bypassed:</strong> Access without login.</li>
<li data-i18n="security.bindings.form.tip_blocked"><strong>Blocked:</strong> Deny access completely.</li>
<li data-i18n="security.bindings.form.tip_regular"><strong>Regular:</strong> Normal hotspot client.</li>
</ul>
</div>
</form>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '', type: '' };
this.init();
}
init() {
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('filter-type').addEventListener('change', (e) => {
this.filters.type = e.target.value;
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const type = row.dataset.type || 'regular';
const mac = row.dataset.mac || '';
const addr = row.dataset.address || '';
const cmt = row.dataset.comment || '';
if (this.filters.type && type !== this.filters.type) return false;
if (this.filters.search) {
if (!mac.includes(this.filters.search) && !addr.includes(this.filters.search) && !cmt.includes(this.filters.search)) return false;
}
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
} else {
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
}
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(s => new CustomSelect(s));
}
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>

View File

@@ -0,0 +1,338 @@
<?php
$title = "Walled Garden";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="security.walled_garden.title">Walled Garden</h1>
<p class="text-accents-5" data-i18n="security.walled_garden.subtitle" data-i18n-params='{"name": "<?= htmlspecialchars($session) ?>"}'>Manage allowed destinations (bypass without login) for: <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary" data-i18n="common.dashboard">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> Dashboard
</a>
</div>
</div>
<?php if ($error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center shadow-sm">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<!-- List (2/3) -->
<div class="lg:col-span-2 space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center bg-card p-4 rounded-lg border border-accents-2 shadow-sm">
<!-- Search -->
<div class="relative w-full">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search host, ip, comment..." data-i18n="common.table.search_placeholder">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-40">
<select id="filter-action" class="custom-select" data-search="true">
<option value="" data-i18n="security.walled_garden.all_actions">All Actions</option>
<option value="allow" data-i18n="security.walled_garden.allow">Allow</option>
<option value="deny" data-i18n="security.walled_garden.deny">Deny</option>
</select>
</div>
</div>
</div>
<div class="table-container">
<table class="table-glass" id="walled-garden-table">
<thead>
<tr>
<th data-i18n="security.walled_garden.table.host_ip">Dst. Host / IP</th>
<th data-i18n="security.walled_garden.table.proto_port">Protocol / Port</th>
<th data-sort="action" class="sortable cursor-pointer hover:text-primary select-none group">
<div class="flex items-center gap-1"><span data-i18n="security.walled_garden.table.action">Action</span> <i data-lucide="arrow-up-down" class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity"></i></div>
</th>
<th data-sort="comment" class="sortable cursor-pointer hover:text-primary select-none group">
<div class="flex items-center gap-1"><span data-i18n="security.walled_garden.table.comment">Comment</span> <i data-lucide="arrow-up-down" class="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity"></i></div>
</th>
<th class="relative text-right" data-i18n="common.actions">Act</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($items)): ?>
<?php foreach ($items as $item): ?>
<tr class="table-row-item"
data-action="<?= htmlspecialchars($item['action'] ?? 'allow') ?>"
data-host="<?= strtolower($item['dst-host'] ?? '') ?>"
data-address="<?= htmlspecialchars($item['dst-address'] ?? '') ?>"
data-comment="<?= strtolower($item['comment'] ?? '') ?>">
<td>
<div class="flex items-center">
<div class="p-1.5 bg-accents-2 rounded mr-2 text-accents-6">
<i data-lucide="globe" class="w-3.5 h-3.5"></i>
</div>
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($item['dst-host'] ?? $item['dst-address'] ?? 'Any') ?></div>
</div>
</td>
<td>
<div class="text-sm text-foreground"><?= htmlspecialchars($item['protocol'] ?? 'Any') ?> : <?= htmlspecialchars($item['dst-port'] ?? 'Any') ?></div>
</td>
<td>
<?php
$actionClass = 'bg-accents-2 text-accents-6 border border-accents-3';
if (($item['action']??'') == 'allow') $actionClass = 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800';
if (($item['action']??'') == 'deny') $actionClass = 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800';
?>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <?= $actionClass ?>">
<?= htmlspecialchars($item['action'] ?? 'allow') ?>
</span>
</td>
<td class="text-sm text-accents-5 italic"><?= htmlspecialchars($item['comment'] ?? '-') ?></td>
<td class="text-right text-sm font-medium">
<div class="flex justify-end">
<form action="/<?= htmlspecialchars($session) ?>/hotspot/walled-garden/remove" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Remove Entry?', 'Are you sure you want to remove this Walled Garden entry?', 'Remove', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $item['.id'] ?>">
<button type="submit" class="btn btn-icon-sm hover:bg-red-50 text-accents-5 hover:text-red-600 transition-colors" title="Remove">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span>
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<!-- Add Form (Sticky Side) -->
<div class="lg:col-span-1">
<div class="card p-0 border-accents-2 shadow-lg sticky top-6">
<div class="p-4 border-b border-accents-2 bg-primary/5 flex items-center gap-2">
<div class="p-1.5 bg-primary/10 rounded text-primary">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
</div>
<h3 class="font-bold text-sm uppercase tracking-wide text-primary" data-i18n="security.walled_garden.form.add_title">Add Entry</h3>
</div>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/walled-garden/store" method="POST" class="p-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-1 gap-4">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<div class="space-y-1.5 md:col-span-2 lg:col-span-1">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.walled_garden.form.dst_host">Dst. Host (Domain)</label>
<div class="relative group">
<span class="absolute left-3 top-2.5 text-accents-4 group-focus-within:text-primary transition-colors pointer-events-none">
<i data-lucide="globe" class="w-4 h-4"></i>
</span>
<input type="text" name="dst_host" class="form-input pl-10" placeholder="example.com">
</div>
<p class="text-xs text-accents-5" data-i18n="security.walled_garden.form.host_help">Domain to allow (wildcard supported).</p>
</div>
<div class="space-y-1.5 md:col-span-2 lg:col-span-1">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.walled_garden.form.dst_address">Dst. Address (IP)</label>
<input type="text" name="dst_address" class="form-input" placeholder="10.5.50.1">
<p class="text-xs text-accents-5" data-i18n="security.walled_garden.form.addr_help">Destination IP Address.</p>
</div>
<div class="grid grid-cols-2 gap-4 md:col-span-2 lg:col-span-1">
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.walled_garden.form.protocol">Protocol</label>
<select name="protocol" class="custom-select w-full">
<option value="(6) tcp">tcp</option>
<option value="(17) udp">udp</option>
<option value="" data-i18n="common.none">any</option>
</select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.walled_garden.form.dst_port">Dst. Port</label>
<input type="text" name="dst_port" class="form-input" placeholder="80,443">
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.walled_garden.form.action">Action</label>
<select name="action" class="custom-select w-full">
<option value="allow" data-i18n="security.walled_garden.allow">allow</option>
<option value="deny" data-i18n="security.walled_garden.deny">deny</option>
</select>
<p class="text-xs text-accents-5" data-i18n="security.walled_garden.form.action_help">Allow (bypass) or Deny access.</p>
</div>
<div class="space-y-1.5">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.walled_garden.form.server">Server</label>
<select name="server" class="custom-select w-full" data-search="true">
<option value="all">all</option>
<!-- Ideally fetch servers -->
</select>
<p class="text-xs text-accents-5" data-i18n="security.walled_garden.form.server_help">Apply to specific Hotspot server.</p>
</div>
<div class="space-y-1.5 md:col-span-2 lg:col-span-1">
<label class="text-xs font-bold text-accents-5 uppercase" data-i18n="security.walled_garden.form.comment">Comment</label>
<input type="text" name="comment" class="form-input" placeholder="Optional notes" data-i18n-placeholder="security.walled_garden.form.comment_help">
<p class="text-xs text-accents-5" data-i18n="security.walled_garden.form.comment_help">Note for this rule.</p>
</div>
<div class="pt-2 md:col-span-2 lg:col-span-1">
<button type="submit" class="btn btn-primary w-full 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="security.walled_garden.form.save">Save Entry</span>
</button>
</div>
<!-- Quick Tips -->
<div class="pt-4 mt-4 border-t border-accents-2 md:col-span-2 lg:col-span-1">
<h4 class="text-xs font-bold text-accents-5 uppercase mb-2 flex items-center gap-1">
<i data-lucide="lightbulb" class="w-3 h-3 text-yellow-500"></i> <span data-i18n="common.tips">Tips</span>
</h4>
<ul class="text-xs text-accents-5 space-y-1.5 list-disc list-inside">
<li data-i18n="security.walled_garden.form.tip_host"><strong>Dst. Host:</strong> Domain name (e.g. <code>*.google.com</code>).</li>
<li data-i18n="security.walled_garden.form.tip_ip"><strong>Dst. IP:</strong> Specific IP address.</li>
<li data-i18n="security.walled_garden.form.tip_action"><strong>Action:</strong> Allow to bypass auth.</li>
</ul>
</div>
</form>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '', action: '' };
this.init();
}
init() {
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('filter-action').addEventListener('change', (e) => {
this.filters.action = e.target.value;
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const action = row.dataset.action || 'allow';
const host = row.dataset.host || '';
const addr = row.dataset.address || '';
const cmt = row.dataset.comment || '';
if (this.filters.action && action !== this.filters.action) return false;
if (this.filters.search) {
if (!host.includes(this.filters.search) && !addr.includes(this.filters.search) && !cmt.includes(this.filters.search)) return false;
}
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
} else {
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
}
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(s => new CustomSelect(s));
}
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>

View File

@@ -0,0 +1,220 @@
<?php
$title = "API CORS";
$no_main_container = true;
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<!-- Sub-Navbar Navigation -->
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.api_cors_title">API CORS</h1>
<p class="text-accents-5 mt-2" data-i18n="settings.api_cors_subtitle">Manage Cross-Origin Resource Sharing for API access.</p>
</div>
<!-- Content Area -->
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div class="hidden md:block">
<!-- Spacer -->
</div>
<div class="flex gap-2 w-full md:w-auto">
<button onclick="openModal('addModal')" class="btn btn-primary w-full md:w-auto">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.add_rule">Add CORS Rule</span>
</button>
</div>
</div>
<div class="table-container">
<table class="table-glass" id="cors-table">
<thead>
<tr>
<th data-i18n="settings.origin">Origin</th>
<th data-i18n="settings.methods">Allowed Methods</th>
<th data-i18n="settings.headers">Allowed Headers</th>
<th class="text-right" data-i18n="common.actions">Actions</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($rules)): ?>
<?php foreach ($rules as $rule): ?>
<tr class="table-row-item">
<td>
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($rule['origin']) ?></div>
<div class="text-xs text-accents-4">Max Age: <?= $rule['max_age'] ?>s</div>
</td>
<td>
<div class="flex flex-wrap gap-1">
<?php foreach ($rule['methods_arr'] as $method): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"><?= htmlspecialchars($method) ?></span>
<?php endforeach; ?>
</div>
</td>
<td>
<div class="text-sm text-accents-5 truncate max-w-[200px]"><?= htmlspecialchars(implode(', ', $rule['headers_arr'])) ?></div>
</td>
<td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal">
<button onclick="editRule(<?= htmlspecialchars(json_encode($rule)) ?>)" class="btn-icon" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i>
</button>
<form action="/settings/api-cors/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.cors_rule_deleted') : 'Delete CORS Rule?', 'Are you sure you want to delete the CORS rule for <?= htmlspecialchars($rule['origin']) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="id" value="<?= $rule['id'] ?>">
<button type="submit" class="btn-icon-danger" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="4" class="px-6 py-12 text-center">
<div class="flex flex-col items-center">
<i data-lucide="shield" class="w-12 h-12 text-accents-2 mb-4"></i>
<p class="text-accents-5">No CORS rules found. Add your first origin to allow external API access.</p>
</div>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Add Modal -->
<div id="addModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('addModal')"></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
<div class="card shadow-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold" data-i18n="settings.add_rule">Add CORS Rule</h3>
<button onclick="closeModal('addModal')" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/settings/api-cors/store" method="POST" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
<input type="text" name="origin" class="form-control" placeholder="https://example.com or *" required>
<p class="text-xs text-orange-500 dark:text-orange-400 mt-1 font-medium">Use * for all origins (not recommended for production).</p>
</div>
<div>
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
<div class="grid grid-cols-3 gap-2">
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox" <?= in_array($m, ['GET', 'POST']) ? 'checked' : '' ?>>
<span class="text-sm"><?= $m ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
<input type="text" name="headers" class="form-control" value="*" placeholder="Content-Type, Authorization, *">
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
<input type="number" name="max_age" class="form-control" value="3600">
</div>
<div class="flex justify-end pt-4">
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Modal -->
<div id="editModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('editModal')"></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
<div class="card shadow-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold" data-i18n="settings.edit_rule">Edit CORS Rule</h3>
<button onclick="closeModal('editModal')" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/settings/api-cors/update" method="POST" class="space-y-4">
<input type="hidden" name="id" id="edit_id">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
<input type="text" name="origin" id="edit_origin" class="form-control" required>
</div>
<div>
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
<div class="grid grid-cols-3 gap-2" id="edit_methods_container">
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox edit-method-check" data-method="<?= $m ?>">
<span class="text-sm"><?= $m ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
<input type="text" name="headers" id="edit_headers" class="form-control">
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
<input type="number" name="max_age" id="edit_max_age" class="form-control">
</div>
<div class="flex justify-end pt-4">
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openModal(id) {
const modal = document.getElementById(id);
const content = modal.querySelector('.modal-content');
modal.classList.remove('hidden');
// Use double requestAnimationFrame to ensure the browser has painted the hidden->block change
// before we trigger the opacity/transform transitions.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
modal.classList.remove('opacity-0');
content.classList.remove('scale-95', 'opacity-0');
content.classList.add('scale-100', 'opacity-100');
});
});
}
function closeModal(id) {
const modal = document.getElementById(id);
const content = modal.querySelector('.modal-content');
modal.classList.add('opacity-0');
content.classList.remove('scale-100', 'opacity-100');
content.classList.add('scale-95', 'opacity-0');
setTimeout(() => { modal.classList.add('hidden'); }, 300);
}
function editRule(rule) {
document.getElementById('edit_id').value = rule.id;
document.getElementById('edit_origin').value = rule.origin;
document.getElementById('edit_headers').value = rule.headers_arr.join(', ');
document.getElementById('edit_max_age').value = rule.max_age;
// Clear and check checkboxes
const methods = rule.methods_arr;
document.querySelectorAll('.edit-method-check').forEach(cb => {
cb.checked = methods.includes(cb.dataset.method);
});
openModal('editModal');
}
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

123
app/Views/settings/form.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
// Use $router variable instead of $session to avoid conflict with header.php logic
$router = $router ?? null;
$title = $router ? "Edit Router" : "Add Router";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Safe access helper
$val = function($key) use ($router) {
return isset($router) && isset($router[$key]) ? htmlspecialchars($router[$key]) : '';
};
?>
<div class="w-full max-w-5xl mx-auto mb-16">
<div class="mb-8">
<a href="/settings/routers" class="inline-flex items-center text-sm text-accents-5 hover:text-foreground transition-colors mb-4">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> Back to Settings
</a>
<h1 class="text-2xl font-bold tracking-tight"><?= $title ?></h1>
<p class="text-accents-5">Connect Mikhmon to your RouterOS device.</p>
</div>
<form autocomplete="off" method="post" action="<?= isset($router) ? '/settings/update' : '/settings/store' ?>">
<?php if(isset($router)): ?>
<input type="hidden" name="id" value="<?= $router['id'] ?>">
<?php endif; ?>
<div class="card p-6 md:p-8 space-y-6">
<div>
<h2 class="text-base font-semibold mb-4">Session Settings</h2>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">Session Name</label>
<input class="form-control w-full" type="text" name="sessname" id="sessname" placeholder="e.g. router-jakarta-1" value="<?= $val('session_name') ?>" required/>
<p class="text-xs text-accents-4">Unique ID. Preview: <span id="sessname-preview" class="font-mono text-primary font-bold">...</span></p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="quick_access" name="quick_access" class="checkbox" <?= (isset($router['quick_access']) && $router['quick_access'] == 1) ? 'checked' : '' ?> value="1">
<label for="quick_access" class="text-sm font-medium cursor-pointer select-none">Show in Quick Access (Home Page)</label>
</div>
</div>
</div>
<div class="border-t border-accents-2 pt-6">
<h2 class="text-base font-semibold mb-4">Connection Details</h2>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">IP Address</label>
<input class="form-control w-full" type="text" name="ipmik" placeholder="192.168.88.1" value="<?= $val('ip_address') ?>" required/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-sm font-medium">Username</label>
<input class="form-control w-full" type="text" name="usermik" placeholder="admin" value="<?= $val('username') ?>" required/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">Password</label>
<input class="form-control w-full" type="password" name="passmik" <?= isset($router) ? '' : 'required' ?> />
<?php if(isset($router)): ?>
<p class="text-xs text-accents-4">Leave empty to keep existing password.</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
<div class="border-t border-accents-2 pt-6">
<h2 class="text-base font-semibold mb-4">Hotspot Information</h2>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">Hotspot Name</label>
<input class="form-control w-full" type="text" name="hotspotname" placeholder="My Hotspot ID" value="<?= $val('hotspot_name') ?>" required/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-sm font-medium">DNS Name</label>
<input class="form-control w-full" type="text" name="dnsname" placeholder="hotspot.net" value="<?= $val('dns_name') ?>" required/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">Traffic Interface</label>
<div class="flex w-full gap-2">
<div class="flex-grow">
<select class="custom-select w-full" name="iface" id="iface" data-search="true" required>
<option value="<?= $val('interface') ?: 'ether1' ?>"><?= $val('interface') ?: 'ether1' ?></option>
</select>
</div>
<button type="button" id="check-interface-btn" class="btn btn-secondary whitespace-nowrap" title="Check connection and fetch interfaces">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Check
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-sm font-medium">Currency</label>
<input class="form-control w-full" type="text" name="currency" value="<?= $val('currency') ?: 'Rp' ?>" required/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">Auto Reload (Sec)</label>
<input class="form-control w-full" type="number" min="10" name="areload" value="<?= $val('reload_interval') ?: 10 ?>" required/>
</div>
</div>
</div>
</div>
<div class="pt-6 flex justify-end gap-3">
<a href="/settings/routers" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-secondary" name="action" value="save">
Save
</button>
<button type="submit" class="btn btn-primary" name="action" value="connect">
Save & Connect
</button>
</div>
</div>
</form>
</div>
<script src="/assets/js/router-form.js"></script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,110 @@
<?php
$title = "Settings";
$no_main_container = true;
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<!-- Sub-Navbar Navigation -->
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight">Router Sessions</h1>
<p class="text-accents-5 mt-2">Manage your stored MikroTik connections.</p>
</div>
</div>
<!-- Content Area -->
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div class="hidden md:block">
<!-- Spacer or Breadcrumbs if needed -->
</div>
<a href="/settings/add" class="btn btn-primary w-full md:w-auto">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add Router
</a>
</div>
<?php if (empty($routers)): ?>
<div class="card flex flex-col items-center justify-center py-16 text-center border-dashed">
<div class="rounded-full bg-accents-1 p-4 mb-4">
<i data-lucide="server-off" class="w-8 h-8 text-accents-4"></i>
</div>
<h3 class="text-lg font-medium mb-2">No routers configured</h3>
<p class="text-accents-5 mb-6 max-w-sm mx-auto">Connect your first MikroTik router to start managing hotspots and vouchers.</p>
<a href="/settings/add" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Connect Router
</a>
</div>
<?php else: ?>
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th scope="col">Session Name</th>
<th scope="col">Hotspot Name</th>
<th scope="col">IP Address</th>
<th scope="col" class="relative text-right">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<?php foreach ($routers as $router): ?>
<tr>
<td>
<div class="flex items-center">
<div class="h-8 w-8 rounded bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
<?= strtoupper(substr($router['session_name'], 0, 2)) ?>
</div>
<div>
<div class="text-sm font-medium text-foreground flex items-center gap-2">
<?= htmlspecialchars($router['session_name']) ?>
<?php if(isset($router['quick_access']) && $router['quick_access'] == 1): ?>
<i data-lucide="star" class="w-3 h-3 text-yellow-500 fill-current" title="Quick Access Enabled"></i>
<?php endif; ?>
</div>
<div class="text-xs text-accents-5">ID: <?= $router['id'] ?></div>
</div>
</div>
</td>
<td>
<div class="text-sm text-foreground"><?= htmlspecialchars($router['hotspot_name']) ?></div>
</td>
<td>
<div class="text-sm text-accents-5 font-mono"><?= htmlspecialchars($router['ip_address']) ?></div>
</td>
<td class="text-right text-sm font-medium flex justify-end gap-2">
<a href="/<?= htmlspecialchars($router['session_name']) ?>/dashboard" class="btn btn-secondary btn-sm h-8 px-3">
Open
</a>
<a href="/settings/edit/<?= $router['id'] ?>" class="btn btn-secondary btn-sm h-8 px-3" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i>
</a>
<form action="/settings/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Disconnect Router?', 'Are you sure you want to disconnect <?= htmlspecialchars($router['session_name']) ?>?', 'Disconnect', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="id" value="<?= $router['id'] ?>">
<button type="submit" class="btn hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 border border-transparent h-8 px-2" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="bg-accents-1 px-4 py-3 border-t border-accents-2 flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 sm:px-6">
<div class="text-sm text-accents-5">
Showing all <?= count($routers) ?> stored sessions
</div>
<a href="/settings/add" class="btn btn-primary btn-sm w-full sm:w-auto justify-center">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add New
</a>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,121 @@
<?php
$title = "Logo Management";
$no_main_container = true;
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<!-- Sub-Navbar Navigation -->
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.logos_title">Logo Management</h1>
<p class="text-accents-5 mt-2" data-i18n="settings.logos_subtitle">Upload and manage logos for your hotspots and vouchers.</p>
</div>
<!-- Content Area -->
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
<div class="space-y-8">
<!-- Section Header (Removed redundant Logos) -->
<!-- Upload Section -->
<section>
<div class="card p-8 border-dashed border-2 bg-accents-1 hover:bg-background transition-colors text-center relative group">
<form action="/settings/logos/upload" method="POST" enctype="multipart/form-data" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.svg,.gif" onchange="this.form.submit()" class="form-control-file">
</form>
<div class="flex flex-col items-center justify-center pointer-events-none">
<div class="h-12 w-12 rounded-full bg-accents-2 flex items-center justify-center mb-4 group-hover:bg-primary group-hover:text-white transition-colors">
<i data-lucide="upload-cloud" class="w-6 h-6"></i>
</div>
<h3 class="text-lg font-medium mb-1" data-i18n="settings.upload_new_logo">Upload New Logo</h3>
<p class="text-sm text-accents-5" data-i18n="settings.drag_drop">Drag and drop or click to select file</p>
<p class="text-xs text-accents-4 mt-2" data-i18n="settings.supports_formats">Supports PNG, JPG, SVG, GIF</p>
</div>
</div>
</section>
<!-- Gallery Section -->
<section>
<?php if (empty($logos)): ?>
<div class="text-center py-12">
<p class="text-accents-5" data-i18n="settings.no_logos">No logos uploaded yet.</p>
</div>
<?php else: ?>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-6">
<?php foreach ($logos as $logo): ?>
<div class="group relative card !p-0 overflow-hidden border border-accents-2 bg-background hover:shadow-md transition-all">
<!-- Image Preview -->
<div class="aspect-square flex items-center justify-center p-4 bg-accents-1 relative" style="background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;">
<img src="<?= $logo['path'] ?>" alt="<?= htmlspecialchars($logo['name']) ?>" class="max-w-full max-h-full object-contain">
<!-- Overlay Actions -->
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
<span class="text-white font-mono text-lg font-bold bg-black/50 px-2 py-1 rounded"><?= $logo['id'] ?></span>
<div class="flex gap-2">
<button onclick="copyToClipboard('<?= $logo['id'] ?>')" class="p-2 bg-white text-black rounded hover:bg-accents-2 transition-colors" title="Copy ID">
<i data-lucide="hash" class="w-4 h-4"></i>
</button>
<form action="/settings/logos/delete" method="POST" class="delete-logo-form">
<input type="hidden" name="id" value="<?= $logo['id'] ?>">
<button type="submit" class="p-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</div>
</div>
<!-- Info -->
<div class="p-3 border-t border-accents-2">
<div class="flex items-center justify-between">
<code class="text-xs font-bold bg-accents-2 px-1 rounded"><?= $logo['id'] ?></code>
<span class="text-xs text-accents-5 uppercase"><?= $logo['type'] ?></span>
</div>
<p class="text-xs text-accents-5 mt-1 truncate" title="<?= htmlspecialchars($logo['name']) ?>"><?= htmlspecialchars($logo['name']) ?></p>
<div class="flex items-center justify-between mt-1 text-xs text-accents-4">
<span><?= $logo['formatted_size'] ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div> <!-- End Space-y-8 -->
</div> <!-- End Content Area -->
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
const title = window.i18n ? window.i18n.t('settings.id_copied') : 'ID Copied';
const desc = window.i18n ? window.i18n.t('settings.logo_id_copied_desc', {id: text}) : `Logo ID <strong>${text}</strong> copied to clipboard.`;
Mivo.alert('success', title, desc);
});
}
document.addEventListener('DOMContentLoaded', () => {
// Intercept Logo Deletion
const deleteForms = document.querySelectorAll('.delete-logo-form');
deleteForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
const logoId = this.querySelector('input[name="id"]').value;
Mivo.confirm(
window.i18n ? window.i18n.t('settings.delete_logo_title') : 'Delete Logo?',
window.i18n ? window.i18n.t('settings.delete_logo_confirm', {id: logoId}) : `Are you sure you want to delete logo <strong>${logoId}</strong>?`,
window.i18n ? window.i18n.t('common.delete') : 'Yes, Delete',
window.i18n ? window.i18n.t('common.cancel') : 'Cancel'
).then((result) => {
if (result) {
this.submit();
}
});
});
});
});
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,122 @@
<?php
$title = "Settings";
$no_main_container = true;
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<!-- Sub-Navbar Navigation -->
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.system">General Settings</h1>
<p class="text-accents-5 mt-2" data-i18n="settings.system_desc">System-wide configurations and security.</p>
</div>
<!-- Content Area -->
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
<div class="space-y-8">
<!-- Section Header (Removed redundant General) -->
<div class="pb-5">
<h3 class="text-lg font-medium leading-6 text-foreground" data-i18n="settings.security">Security & Access</h3>
</div>
<!-- Admin Password -->
<div class="card">
<form action="/settings/admin/update" method="POST" class="space-y-6">
<div class="grid grid-cols-1 gap-6 w-full">
<div class="space-y-2">
<label class="block text-sm font-medium text-foreground" data-i18n="settings.admin_username">Admin Username</label>
<div class="relative">
<input type="text" class="form-control w-full bg-accents-1 text-accents-5 cursor-not-allowed pl-10" value="<?= htmlspecialchars($username) ?>" readonly disabled>
<i data-lucide="lock" class="absolute left-3 top-2.5 h-4 w-4 text-accents-4"></i>
</div>
<p class="text-xs text-accents-4" data-i18n="settings.admin_username_desc">
<i class="inline-block w-3 h-3 mr-1 align-middle" data-lucide="info"></i>
For security reasons, the administrator username cannot be changed.
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-foreground" data-i18n="settings.change_password">Change Password</label>
<div class="relative">
<input type="password" name="admin_password" class="form-control w-full pl-10" placeholder="Enter new password" data-i18n-placeholder="settings.new_password_placeholder">
<i data-lucide="key" class="absolute left-3 top-2.5 h-4 w-4 text-accents-4"></i>
</div>
</div>
</div>
<div class="pt-4 border-t border-accents-2">
<button type="submit" class="btn btn-primary">
<span data-i18n="settings.update_password">Update Password</span>
</button>
</div>
</form>
</div>
<!-- Global Configuration -->
<div class="card">
<form action="/settings/global/update" method="POST" class="space-y-6">
<div class="grid grid-cols-1 gap-6 w-full">
<div class="space-y-2">
<label class="block text-sm font-medium text-foreground" data-i18n="settings.quick_print_mode">Quick Print Mode</label>
<div class="relative">
<select name="quick_print_mode" class="custom-select w-full">
<option value="0" <?= ($settings['quick_print_mode'] ?? '0') == '0' ? 'selected' : '' ?> data-i18n="common.forms.disabled">Disabled</option>
<option value="1" <?= ($settings['quick_print_mode'] ?? '0') == '1' ? 'selected' : '' ?> data-i18n="common.forms.enabled">Enabled</option>
</select>
</div>
<p class="text-xs text-accents-4" data-i18n="settings.quick_print_mode_desc">Enable direct printing for voucher generation.</p>
</div>
</div>
<div class="pt-4 border-t border-accents-2 mt-6">
<button type="submit" class="btn btn-primary" data-i18n="settings.save_global">
Save Global Settings
</button>
</div>
</form>
</div>
<!-- Data Management -->
<div class="card">
<div class="mb-6">
<h4 class="text-lg font-medium" data-i18n="settings.data_management">Data Management</h4>
<p class="text-sm text-accents-5" data-i18n="settings.data_management_desc">Backup or restore your application data.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Backup -->
<div class="p-4 rounded-lg bg-accents-1 border border-accents-2 flex flex-col h-full">
<div class="flex-1">
<h4 class="font-medium mb-2 text-sm" data-i18n="settings.backup_data">Backup Data</h4>
<p class="text-xs text-accents-5 mb-4" data-i18n="settings.backup_data_desc">Download a configuration file (.mivo) containing your database and settings.</p>
</div>
<a href="/settings/backup" class="btn btn-primary w-full justify-center text-sm mt-auto">
<i data-lucide="download" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.download_backup">Download Backup</span>
</a>
</div>
<!-- Restore -->
<div class="p-4 rounded-lg bg-accents-1 border border-accents-2 flex flex-col h-full">
<div class="flex-1">
<h4 class="font-medium mb-2 text-sm" data-i18n="settings.restore_data">Restore Data</h4>
<p class="text-xs text-accents-5 mb-4" data-i18n="settings.restore_data_desc">Upload a previously backup file (.mivo). <strong>Overwrites or adds to existing data.</strong></p>
</div>
<form action="/settings/restore" method="POST" enctype="multipart/form-data" class="flex flex-col sm:flex-row gap-2 mt-auto">
<div class="w-full">
<input type="file" name="backup_file" accept=".mivo" class="form-control-file" required>
</div>
<button type="submit" class="btn btn-primary w-full sm:w-auto mt-2 sm:mt-0" onclick="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.restore_data') : 'Restore Data?', window.i18n ? window.i18n.t('settings.warning_restore') : 'WARNING: This will restore settings from the file and may overwrite existing data. Continue?', window.i18n ? window.i18n.t('settings.restore') : 'Restore', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.closest('form').submit(); });">
<span data-i18n="settings.restore">Restore</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,3 @@
<?php
// Just include the edit view, logic is handled there
include __DIR__ . '/edit.php';

View File

@@ -0,0 +1,488 @@
<?php
// Template Editor (Shared for Add/Edit)
$isEdit = isset($template);
$title = $isEdit ? 'Edit Template' : 'New Template';
$initialContent = $template['content'] ?? '<div style="border: 1px solid #000; padding: 10px; width: 300px; background-color: #fff;">
<h3>{{dns_name}}</h3>
<p>User: {{username}}</p>
<p>Pass: {{password}}</p>
<p>Price: {{price}}</p>
<p>Valid: {{validity}}</p>
</div>';
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex flex-col lg:h-[calc(100vh-8rem)] gap-6">
<!-- Header -->
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 flex-shrink-0">
<div class="flex items-center gap-4">
<a href="/settings/templates" class="text-accents-5 hover:text-foreground transition-colors">
<i data-lucide="arrow-left" class="w-5 h-5"></i>
</a>
<h1 class="text-xl font-bold tracking-tight text-foreground"><?= $title ?></h1>
</div>
<form id="templateForm" action="<?= $isEdit ? '/settings/templates/update' : '/settings/templates/store' ?>" method="POST" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 w-full lg:w-auto">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= $template['id'] ?>">
<?php endif; ?>
<input type="text" name="name" value="<?= htmlspecialchars($template['name'] ?? 'New Template') ?>" required class="form-input w-full lg:w-64" placeholder="Template Name" data-i18n-placeholder="settings.template_name">
<button type="submit" class="btn btn-primary h-9 justify-center">
<i data-lucide="save" class="w-4 h-4 mr-2"></i> <span data-i18n="common.save">Save</span>
</button>
</form>
</div>
<!-- Editor Layout -->
<div class="flex-1 flex flex-col lg:flex-row gap-6 overflow-hidden min-h-0">
<!-- Left: Code Editor -->
<div class="flex-1 flex flex-col bg-background border border-accents-2 rounded-lg overflow-hidden min-w-0 min-h-0 h-[400px] sm:h-[500px] lg:h-auto shrink-0">
<div class="bg-accents-1 px-4 py-3 border-b border-accents-2 flex items-center justify-between gap-4">
<span class="text-xs font-mono font-medium text-accents-5 whitespace-nowrap" data-i18n="settings.html_source">HTML Source</span>
<!-- Scrollable Toolbar -->
<div class="flex-1 flex gap-2 overflow-x-auto no-scrollbar mask-fade-right py-1 px-1">
<div class="flex gap-2 whitespace-nowrap">
<!-- Help Button -->
<button type="button" onclick="toggleDocs()" class="text-xs px-2 py-1 bg-accents-2 hover:bg-accents-3 text-accents-8 rounded transition-colors flex items-center gap-1">
<i data-lucide="help-circle" class="w-3 h-3"></i> <span data-i18n="settings.docs">Docs</span>
</button>
<button type="button" onclick="insertVar('{{username}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{username}}</button>
<button type="button" onclick="insertVar('{{password}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{password}}</button>
<button type="button" onclick="insertVar('{{price}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{price}}</button>
<button type="button" onclick="insertVar('{{validity}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{validity}}</button>
<button type="button" onclick="insertVar('{{timelimit}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{timelimit}}</button>
<button type="button" onclick="insertVar('{{datalimit}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{datalimit}}</button>
<button type="button" onclick="insertVar('{{profile}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{profile}}</button>
<button type="button" onclick="insertVar('{{dns_name}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{dns_name}}</button>
<button type="button" onclick="insertVar('{{login_url}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors">{{login_url}}</button>
<button type="button" onclick="insertVar('{{qrcode}}')" class="text-xs px-2 py-1 bg-background border border-accents-2 rounded hover:bg-accents-2 transition-colors" title="Insert QR Code">{{qrcode}}</button>
</div>
</div>
</div>
<textarea id="codeEditor" name="content" form="templateForm" class="form-control flex-1 w-full font-mono text-sm resize-none h-[500px]" spellcheck="false"><?= htmlspecialchars($initialContent) ?></textarea>
</div>
<!-- Right: Preview -->
<div class="flex-1 flex flex-col border border-accents-2 rounded-lg bg-accents-1 relative overflow-hidden min-h-[500px] shrink-0 lg:h-auto lg:min-h-0">
<div class="bg-background px-4 py-2 border-b border-accents-2 flex items-center justify-between">
<span class="text-xs font-mono font-medium text-accents-5" data-i18n="settings.live_preview">Live Preview</span>
<i data-lucide="refresh-cw" class="w-4 h-4 text-accents-5 cursor-pointer hover:text-foreground" onclick="updatePreview()"></i>
</div>
<!-- Scaled Preview Container - White Paper Simulation -->
<div class="flex-1 overflow-auto flex items-center justify-center p-8 bg-zinc-900/50">
<div id="previewContainer" class="bg-white text-black shadow-xl p-4 min-w-[300px] min-h-[300px] flex items-center justify-center rounded-sm">
<!-- Content Injected Here -->
</div>
</div>
</div>
</div>
</div>
<script src="/assets/js/qrious.min.js"></script>
</div>
<!-- Documentation Modal -->
<div id="docsModal" class="fixed inset-0 z-50 hidden transition-all duration-200">
<!-- Backdrop -->
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm opacity-0 transition-opacity duration-200" onclick="toggleDocs()"></div>
<!-- Modal Content -->
<div class="absolute inset-x-0 top-[10%] mx-auto max-w-2xl bg-background border border-accents-2 shadow-2xl rounded-xl overflow-hidden flex flex-col max-h-[80vh] opacity-0 scale-95 transition-all duration-200 origin-top">
<div class="px-6 py-4 border-b border-accents-2 flex items-center justify-between">
<h2 class="text-lg font-bold" data-i18n="settings.template_variables">Template Variables</h2>
<button onclick="toggleDocs()" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="p-6 overflow-y-auto custom-scrollbar">
<div class="prose dark:prose-invert max-w-none">
<p class="text-sm text-accents-5 mb-4" data-i18n="settings.variables_desc">Use these variables in your HTML source. They will be replaced with actual user data during printing.</p>
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2">Basic Variables</h3>
<div class="grid grid-cols-1 gap-2 mb-6">
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{username}}</code>
<span class="text-sm text-accents-6">Username</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{password}}</code>
<span class="text-sm text-accents-6">Password</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{price}}</code>
<span class="text-sm text-accents-6">Price (formatted)</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{validity}}</code>
<span class="text-sm text-accents-6">Validity (Raw)</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{timelimit}}</code>
<span class="text-sm text-accents-6">Time Limit (Formatted)</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{datalimit}}</code>
<span class="text-sm text-accents-6">Data Limit (Formatted)</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{profile}}</code>
<span class="text-sm text-accents-6">User Profile Name</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{dns_name}}</code>
<span class="text-sm text-accents-6">DNS Name / Hotspot Name</span>
</div>
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
<code class="text-sm font-mono text-primary">{{login_url}}</code>
<span class="text-sm text-accents-6">Login URL (http://dnsname/login)</span>
</div>
</div>
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2" data-i18n="settings.qr_code">QR Code</h3>
<div class="p-4 rounded bg-accents-1 border border-accents-2">
<p class="mb-2"><code class="text-sm font-mono text-primary">{{qrcode}}</code></p>
<p class="text-sm text-accents-6 mb-4" data-i18n="settings.qr_desc">Generates a QR Code containing the Login URL with username and password.</p>
<h4 class="text-xs font-bold uppercase text-accents-5 mb-2" data-i18n="settings.custom_attributes">Custom Attributes</h4>
<ul class="text-sm space-y-2 list-disc list-inside text-accents-6 mb-4">
<li><strong class="text-foreground">fg</strong>: Foreground color (name or hex)</li>
<li><strong class="text-foreground">bg</strong>: Background color (name or hex)</li>
<li><strong class="text-foreground">size</strong>: Size in pixels (default 100)</li>
<li><strong class="text-foreground">padding</strong>: Padding around QR code (pixels)</li>
<li><strong class="text-foreground">rounded</strong>: Corner radius (pixels)</li>
</ul>
<h4 class="text-xs font-bold uppercase text-accents-5 mb-1" data-i18n="settings.examples">Examples:</h4>
<div class="bg-background p-2 rounded border border-accents-2 space-y-1 font-mono text-xs">
<p>{{qrcode fg=red bg=yellow}}</p>
<p>{{qrcode size=200 padding=10 rounded=15}}</p>
<p>{{qrcode fg=#000 bg=#fff}}</p>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-accents-2 bg-accents-1 flex justify-end">
<button onclick="toggleDocs()" class="btn btn-secondary" data-i18n="common.cancel">Close</button>
</div>
</div>
</div>
<script>
// --- Documentation Modal Animation ---
function toggleDocs() {
const modal = document.getElementById('docsModal');
const content = modal.querySelector('div.bg-background'); // The modal card
if (modal.classList.contains('hidden')) {
// Open
modal.classList.remove('hidden');
// Small delay to allow display:block to apply before opacity transition
setTimeout(() => {
modal.firstElementChild.classList.remove('opacity-0'); // Backdrop
content.classList.remove('opacity-0', 'scale-95');
content.classList.add('opacity-100', 'scale-100');
}, 10);
} else {
// Close
modal.firstElementChild.classList.add('opacity-0');
content.classList.remove('opacity-100', 'scale-100');
content.classList.add('opacity-0', 'scale-95');
setTimeout(() => {
modal.classList.add('hidden');
}, 200); // Match duration
}
}
// --- Editor Logic ---
const editor = document.getElementById('codeEditor');
const preview = document.getElementById('previewContainer');
// History Stack for Undo/Redo
let historyStack = [];
let redoStack = [];
let isTyping = false;
let typingTimer = null;
// Initial State
historyStack.push({ value: editor.value, selectionStart: 0, selectionEnd: 0 });
function saveState() {
// Limit stack size
if (historyStack.length > 50) historyStack.shift();
const lastState = historyStack[historyStack.length - 1];
if (lastState && lastState.value === editor.value) return; // No change
historyStack.push({
value: editor.value,
selectionStart: editor.selectionStart,
selectionEnd: editor.selectionEnd
});
redoStack = []; // Clear redo on new change
}
// Debounced save for typing
editor.addEventListener('input', (e) => {
if (!isTyping) {
// Save state *before* a burst of typing starts?
// Actually usually we save *after*.
// For robust undo: save state Before modification if possible, or assume previous state is safe.
// Simplified: Save debounced.
clearTimeout(typingTimer);
typingTimer = setTimeout(saveState, 500);
}
updatePreview();
});
// --- Keyboard Shortcuts (Undo/Redo, Tab, Enter) ---
editor.addEventListener('keydown', function(e) {
// Undo: Ctrl+Z
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
// Redo: Ctrl+Y or Ctrl+Shift+Z
if (((e.ctrlKey || e.metaKey) && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey)) {
e.preventDefault();
redo();
return;
}
// Tab: Insert/Remove Indent
if (e.key === 'Tab') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
const val = this.value;
const tabChar = " "; // 4 spaces
if (e.shiftKey) {
// Un-indent (naive single line)
// TODO: Multiline support if needed. For now simple cursor unindent.
// Checking previous chars
// Not implemented for simplicity, just preventing focus loss.
} else {
// Insert Tab
// Use setRangeText to preserve browser undo buffer if mixed usage?
// But we have custom undo.
this.value = val.substring(0, start) + tabChar + val.substring(end);
this.selectionStart = this.selectionEnd = start + tabChar.length;
saveState();
updatePreview();
}
}
// Enter: Auto-indent checking previous line
if (e.key === 'Enter') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
const val = this.value;
// Find start of current line
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
const currentLine = val.substring(lineStart, start);
// Calculate indentation
const match = currentLine.match(/^\s*/);
const indent = match ? match[0] : '';
const insert = '\n' + indent;
this.value = val.substring(0, start) + insert + val.substring(end);
this.selectionStart = this.selectionEnd = start + insert.length;
saveState(); // Immediate save on Enter
updatePreview();
}
});
function undo() {
if (historyStack.length > 1) { // Keep initial state
const current = historyStack.pop();
redoStack.push(current);
const prev = historyStack[historyStack.length - 1];
editor.value = prev.value;
editor.selectionStart = prev.selectionStart;
editor.selectionEnd = prev.selectionEnd;
updatePreview();
}
}
function redo() {
if (redoStack.length > 0) {
const next = redoStack.pop();
historyStack.push(next);
editor.value = next.value;
editor.selectionStart = next.selectionStart;
editor.selectionEnd = next.selectionEnd;
updatePreview();
}
}
function insertVar(text) {
saveState(); // Save state before insertion
const start = editor.selectionStart;
const end = editor.selectionEnd;
const val = editor.value;
editor.value = val.substring(0, start) + text + val.substring(end);
editor.selectionStart = editor.selectionEnd = start + text.length;
editor.focus();
saveState(); // Save state after insertion
updatePreview();
}
// Live Preview Logic
// Inject Logo Map from PHP
const logoMap = <?= json_encode($logoMap ?? []) ?>;
// Sample Data for Preview
const sampleData = {
'{{username}}': 'user123',
'{{password}}': 'pass789',
'{{price}}': 'Rp 5.000',
'{{validity}}': ' 3 Hours',
'{{timelimit}}': ' 3 Hours',
'{{datalimit}}': '500 MB',
'{{profile}}': 'General',
'{{comment}}': 'mikhmon',
'{{hotspotname}}': 'Mikhmon Hotspot',
'{{num}}': '1',
'{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder
'{{dns_name}}': 'hotspot.mikhmon',
'{{login_url}}': 'http://hotspot.mikhmon/login',
};
function updatePreview() {
let content = editor.value;
// 1. Handle {{logo id=...}}
content = content.replace(/\{\{logo\s+id=['"]?([^'"\s]+)['"]?\}\}/gi, (match, id) => {
if (logoMap[id]) {
return `<img src="${logoMap[id]}" style="height:50px; width:auto;">`;
}
return '';
});
// 2. Simple Replace for other variables
for (const [key, value] of Object.entries(sampleData)) {
content = content.replaceAll(key, value);
}
// 3. Handle QR Code - Local Generation with Attributes
content = content.replace(/\{\{qrcode(?:\s+(.*?))?\}\}/gi, (match, attrs) => {
const qrValue = sampleData['{{login_url}}'] + '?user=' + sampleData['{{username}}'] + '&password=' + sampleData['{{password}}'];
let opts = {
value: qrValue,
size: 100,
foreground: 'black',
};
let roundedStyle = '';
// Default styling options
let styleOpts = {
padding: 0,
background: 'white',
logo: null
};
opts.backgroundAlpha = 0;
if (attrs) {
const fgMatch = attrs.match(/fg\s*=\s*['"]?([^'"\s]+)['"]?/i);
if (fgMatch) opts.foreground = fgMatch[1];
const bgMatch = attrs.match(/bg\s*=\s*['"]?([^'"\s]+)['"]?/i);
if (bgMatch) styleOpts.background = bgMatch[1];
const sizeMatch = attrs.match(/size\s*=\s*['"]?(\d+)['"]?/i);
if (sizeMatch) opts.size = parseInt(sizeMatch[1]);
const paddingMatch = attrs.match(/padding\s*=\s*['"]?(\d+)['"]?/i);
if (paddingMatch) styleOpts.padding = parseInt(paddingMatch[1]);
const roundedMatch = attrs.match(/rounded\s*=\s*['"]?(\d+)['"]?/i);
if (roundedMatch) roundedStyle = `border-radius: ${roundedMatch[1]}px;`;
const logoMatch = attrs.match(/logo\s*=\s*['"]?([^'"\s]+)['"]?/i);
if (logoMatch) styleOpts.logo = logoMatch[1];
}
const qr = new QRious(opts);
const qrDataUrl = qr.toDataURL();
// Construct compound style
const cssBg = `background-color: ${styleOpts.background};`;
const cssPadding = styleOpts.padding ? `padding: ${styleOpts.padding}px;` : '';
const baseStyle = `display: inline-block; vertical-align: middle; ${cssBg} ${cssPadding} ${roundedStyle}`;
// If Logo requested, we need Canvas manipulation.
if (styleOpts.logo && logoMap[styleOpts.logo]) {
// Create a canvas (not added to DOM) to draw composite
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const size = opts.size;
canvas.width = size;
canvas.height = size;
// Since QRious gives dataURL, we need to load it back
// But wait, this is synchronous preview. Loading image is async.
// We can return a placeholder or handle async?
// Simple hack: Return an IMG with a unique class, script loads it?
// Or better: Just render the QR + Logo overlay using CSS absolute positioning?
// Print view uses Canvas. Live Preview uses innerHTML.
// CSS Overlay is easiest for preview, but Print View logic uses Canvas-drawing.
// Let's stick to Canvas drawing for 1:1 fidelity, BUT we need async handling.
// We can use a unique ID + script injection like print view?
// Yes, let's replicate print view logic.
const uniqueId = 'preview-qr-' + Math.random().toString(36).substr(2, 9);
const logoPath = logoMap[styleOpts.logo];
// Generate Script to execute after insertion
// We need to delay execution until element exists.
// Note: innerHTML scripts don't run automatically in all contexts, but updatePreview sets innerHTML.
// Scripts inserted via innerHTML do NOT execute.
// We need another way or just CSS overlay for preview.
// CSS Overlay Approach for Preview (Simpler/Faster)
// <div style="position:relative; ...">
// <img src="QR">
// <img src="LOGO" style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); width:20%;">
// </div>
return `<div style="position:relative; ${baseStyle}">
<img src="${qrDataUrl}" style="display:block;">
<img src="${logoPath}" style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); width:20%; height:auto;">
</div>`;
}
return '<img src="' + qrDataUrl + '" alt="QR Code" style="' + baseStyle + '">';
});
preview.innerHTML = content;
}
editor.addEventListener('input', updatePreview); // Handled by debouncer above too, but OK.
// Init
updatePreview();
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,181 @@
<?php
$title = "Voucher Templates";
$no_main_container = true;
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<!-- Sub-Navbar Navigation -->
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.templates_title">Voucher Templates</h1>
<p class="text-accents-5 mt-2" data-i18n="settings.templates_subtitle">Manage and customize your voucher print designs.</p>
</div>
<!-- Content Area -->
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between border-b border-accents-2 pb-5 gap-4">
<div class="hidden md:block">
<!-- Spacer -->
</div>
<a href="/settings/templates/add" class="btn btn-primary w-full sm:w-auto justify-center">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
<span data-i18n="settings.new_template">New Template</span>
</a>
</div>
<!-- Template List -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Default Template Card (Read Only) -->
<div class="border border-accents-2 rounded-xl overflow-hidden bg-background flex flex-col h-full">
<div class="aspect-video bg-accents-1 border-b border-accents-2 w-full h-full relative overflow-hidden flex items-center justify-center group">
<!-- Loading Overlay -->
<div class="absolute inset-0 flex items-center justify-center bg-accents-1 z-10 transition-opacity duration-500 pointer-events-none input-overlay">
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
</div>
<iframe
data-src="/settings/templates/preview/default"
src="about:blank"
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
scrolling="no"
></iframe>
</div>
<div class="p-4 flex flex-col flex-grow">
<div class="flex items-center justify-between mb-2">
<h3 class="font-bold text-foreground" data-i18n="settings.default_template">Default Template</h3>
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-accents-2 text-foreground" data-i18n="settings.system_label">System</span>
</div>
<p class="text-sm text-accents-5 mb-4" data-i18n="settings.default_template_desc">Standard thermal printer friendly template.</p>
<button disabled class="w-full py-2 border border-accents-2 rounded text-accents-4 text-sm cursor-not-allowed mt-auto" data-i18n="settings.built_in">
Built-in
</button>
</div>
</div>
<?php if (!empty($templates)): ?>
<?php foreach ($templates as $tpl): ?>
<div class="border border-accents-2 rounded-xl overflow-hidden bg-background hover:shadow-sm transition-shadow flex flex-col h-full">
<div class="aspect-video bg-white relative group overflow-hidden">
<?php if (!empty($tpl['content'])): ?>
<div class="w-full h-full bg-accents-1 relative overflow-hidden flex items-center justify-center group">
<!-- Loading Overlay -->
<div class="absolute inset-0 flex items-center justify-center bg-accents-1 z-10 transition-opacity duration-500 pointer-events-none input-overlay">
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
</div>
<iframe
data-src="/settings/templates/preview/<?= $tpl['id'] ?>"
src="about:blank"
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
scrolling="no"
></iframe>
</div>
<?php else: ?>
<!-- Placeholder for Preview Thumb -->
<div class="absolute inset-0 flex items-center justify-center text-accents-3 bg-accents-1">
<i data-lucide="file-code" class="w-8 h-8 opacity-50"></i>
</div>
<?php endif; ?>
</div>
<div class="p-4 flex flex-col flex-grow">
<div class="flex items-center justify-between mb-2">
<h3 class="font-bold text-foreground"><?= htmlspecialchars($tpl['name']) ?></h3>
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" data-i18n="settings.custom_label">Custom</span>
</div>
<p class="text-sm text-accents-5 mb-4 line-clamp-1">Created: <?= htmlspecialchars($tpl['created_at']) ?></p>
<div class="flex items-center gap-2 mt-auto">
<a href="/settings/templates/edit/<?= $tpl['id'] ?>" class="flex-1 btn btn-primary flex justify-center">
<i data-lucide="edit-3" class="w-4 h-4 mr-2"></i> <span data-i18n="common.edit">Edit</span>
</a>
<form action="/settings/templates/delete" method="POST" class="delete-template-form">
<input type="hidden" name="id" value="<?= $tpl['id'] ?>">
<input type="hidden" name="template_name" value="<?= htmlspecialchars($tpl['name']) ?>">
<button type="submit" class="p-2 btn btn-secondary hover:text-red-600 hover:bg-red-50 transition-colors h-9 w-9 flex items-center justify-center">
<i data-lucide="trash-2" class="w-5 h-5"></i>
</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div> <!-- End Space-y-6 -->
</div> <!-- End Content Area -->
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Intercept Template Deletion
const deleteForms = document.querySelectorAll('.delete-template-form');
deleteForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
const templateName = this.querySelector('input[name="template_name"]').value;
Mivo.confirm(
window.i18n ? window.i18n.t('settings.delete_template_title') : 'Delete Template?',
window.i18n ? window.i18n.t('settings.delete_template_confirm', {name: templateName}) : `Are you sure you want to delete <strong>${templateName}</strong>?`,
window.i18n ? window.i18n.t('common.delete') : 'Yes, Delete',
window.i18n ? window.i18n.t('common.cancel') : 'Cancel'
).then((result) => {
if (result) {
this.submit();
}
});
});
});
const queue = [];
let activeRequests = 0;
const CONCURRENCY_LIMIT = 3; // "Threads"
const processQueue = () => {
// Fill up to the limit
while (activeRequests < CONCURRENCY_LIMIT && queue.length > 0) {
const iframe = queue.shift();
activeRequests++;
// Set src to trigger load
iframe.src = iframe.dataset.src;
// On load (or error), fade in and process next slot
const onComplete = () => {
iframe.classList.remove('opacity-0');
if(iframe.previousElementSibling && iframe.previousElementSibling.classList.contains('input-overlay')) {
iframe.previousElementSibling.classList.add('opacity-0');
}
activeRequests--;
setTimeout(processQueue, 50); // Small delay
};
iframe.onload = onComplete;
iframe.onerror = onComplete;
}
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const iframe = entry.target;
// Only queue if it hasn't started loading (src is blank) and isn't already queued
if (iframe.getAttribute('src') === 'about:blank' && !iframe.classList.contains('queued')) {
iframe.classList.add('queued');
queue.push(iframe);
processQueue();
}
}
});
}, { rootMargin: '200px' }); // Preload ahead slightly
document.querySelectorAll('iframe[data-src]').forEach(iframe => {
observer.observe(iframe);
});
});
</script>

270
app/Views/status/active.php Normal file
View File

@@ -0,0 +1,270 @@
<?php
$title = "Active Users";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Filter Data
$uniqueServers = [];
if (!empty($items)) {
foreach ($items as $item) {
$s = $item['server'] ?? '';
if(!empty($s)) $uniqueServers[$s] = $s;
}
}
sort($uniqueServers);
?>
<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_active.title">Active Users</h1>
<p class="text-accents-5"><span data-i18n="hotspot_active.subtitle">Monitor currently active hotspot sessions</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/users" class="btn btn-secondary">
<i data-lucide="users" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_menu.users">Users List</span>
</a>
</div>
</div>
<?php if ($error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search user, mac, ip...">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-40">
<select id="filter-server" class="custom-select" data-search="true">
<option value="" data-i18n="hotspot_active.filter_server">All Servers</option>
<?php foreach($uniqueServers as $s): ?>
<option value="<?= htmlspecialchars($s) ?>"><?= htmlspecialchars($s) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<!-- Table -->
<div class="table-container">
<table class="table-glass" id="active-table">
<thead>
<tr>
<th data-sort="server" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_active.server">Server</th>
<th data-sort="user" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_active.user">User</th>
<th data-i18n="hotspot_active.address">Address / MAC</th>
<th data-i18n="hotspot_active.uptime">Uptime / Left</th>
<th data-i18n="hotspot_active.bytes_in_out">Bytes In/Out</th>
<th class="relative text-right" data-i18n="common.actions">
Actions
</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($items)): ?>
<?php foreach ($items as $item): ?>
<tr class="table-row-item"
data-server="<?= htmlspecialchars($item['server'] ?? '') ?>"
data-user="<?= strtolower($item['user'] ?? '') ?>"
data-address="<?= htmlspecialchars($item['address'] ?? '') ?>"
data-mac="<?= strtolower($item['mac-address'] ?? '') ?>">
<td>
<span class="text-sm font-medium text-foreground"><?= htmlspecialchars($item['server'] ?? '-') ?></span>
</td>
<td>
<div class="flex items-center">
<div class="h-8 w-8 rounded bg-green-100 dark:bg-green-900/30 flex items-center justify-center text-xs font-bold mr-3 text-green-700 dark:text-green-400">
<i data-lucide="wifi" class="w-4 h-4"></i>
</div>
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($item['user'] ?? '-') ?></div>
</div>
</td>
<td>
<div class="text-sm text-foreground"><?= htmlspecialchars($item['address'] ?? '-') ?></div>
<div class="text-xs text-accents-5 font-mono"><?= htmlspecialchars($item['mac-address'] ?? '-') ?></div>
</td>
<td>
<div class="text-sm text-foreground"><?= \App\Helpers\FormatHelper::elapsedTime($item['uptime'] ?? '0s') ?></div>
<?php if(isset($item['session-time-left'])): ?>
<div class="text-xs text-accents-5"><span data-i18n="hotspot_active.time_left">Left</span>: <?= \App\Helpers\FormatHelper::elapsedTime($item['session-time-left']) ?></div>
<?php endif; ?>
</td>
<td>
<div class="text-xs text-accents-5 flex flex-col gap-1">
<span class="flex items-center"><i data-lucide="arrow-down" class="w-3 h-3 mr-1 text-green-500"></i> <?= \App\Helpers\FormatHelper::formatBytes($item['bytes-in'] ?? 0) ?></span>
<span class="flex items-center"><i data-lucide="arrow-up" class="w-3 h-3 mr-1 text-blue-500"></i> <?= \App\Helpers\FormatHelper::formatBytes($item['bytes-out'] ?? 0) ?></span>
</div>
</td>
<td class="text-right text-sm font-medium">
<div class="flex items-center justify-end">
<form action="/<?= htmlspecialchars($session) ?>/hotspot/active/remove" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('hotspot_active.remove') : 'Disconnect User?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to disconnect user <?= htmlspecialchars($item['user'] ?? '') ?>?', window.i18n ? window.i18n.t('hotspot_active.remove') : 'Disconnect', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $item['.id'] ?>">
<button type="submit" class="btn bg-red-50 hover:bg-red-100 text-red-600 dark:bg-red-900/20 dark:hover:bg-red-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Disconnect">
<i data-lucide="x-circle" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> active
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '', server: '' };
this.init();
}
init() {
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
document.getElementById('filter-server').addEventListener('change', (e) => {
this.filters.server = e.target.value;
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const svr = row.dataset.server || '';
const user = row.dataset.user || '';
const mac = row.dataset.mac || '';
const addr = row.dataset.address || '';
if (this.filters.server && svr !== this.filters.server) return false;
if (this.filters.search) {
if (!user.includes(this.filters.search) && !mac.includes(this.filters.search) && !addr.includes(this.filters.search)) return false;
}
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible, or reconstruct
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) container.innerHTML = text; // This replaces the span structure, need to be careful
// Actually, the structure is "Showing <span>..</span> to <span>..</span>".
// Our translation string is "Showing {start} to {end} of {total} active"
// So we can just replace the whole innerHTML of the container
if(container) {
// Re-render with spans for consistent styling if needed, or just text
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(s => new CustomSelect(s));
}
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>

241
app/Views/status/hosts.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
$title = "Hotspot Hosts";
require_once ROOT . '/app/Views/layouts/header_main.php';
// Filter Data
$uniqueServers = [];
if (!empty($items)) {
foreach ($items as $item) {
$s = $item['server'] ?? '';
if(!empty($s)) $uniqueServers[$s] = $s;
}
}
sort($uniqueServers);
?>
<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_hosts.title">Hotspot Hosts</h1>
<p class="text-accents-5"><span data-i18n="hotspot_hosts.subtitle">Devices connected to the hotspot network for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a>
</div>
</div>
<?php if ($error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search mac, ip, comment...">
</div>
<!-- Dropdowns -->
<div class="flex gap-2 w-full md:w-auto">
<div class="w-40">
<select id="filter-server" class="custom-select" data-search="true">
<option value="" data-i18n="hotspot_active.filter_server">All Servers</option>
<?php foreach($uniqueServers as $s): ?>
<option value="<?= htmlspecialchars($s) ?>"><?= htmlspecialchars($s) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="table-container">
<table class="table-glass" id="hosts-table">
<thead>
<tr>
<th data-i18n="hotspot_hosts.mac">MAC Address</th>
<th data-i18n="hotspot_hosts.address">Address</th>
<th data-i18n="hotspot_hosts.to_address">To Address</th>
<th data-sort="server" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_hosts.server">Server</th>
<th data-sort="comment" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="hotspot_hosts.comment">Comment</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($items)): ?>
<?php foreach ($items as $item): ?>
<tr class="table-row-item"
data-server="<?= htmlspecialchars($item['server'] ?? '') ?>"
data-mac="<?= strtolower($item['mac-address'] ?? '') ?>"
data-address="<?= htmlspecialchars($item['address'] ?? '') ?>"
data-comment="<?= strtolower($item['comment'] ?? '') ?>">
<td>
<div class="flex items-center">
<i data-lucide="smartphone" class="w-4 h-4 mr-2 text-accents-4"></i>
<span class="font-mono text-sm text-foreground"><?= htmlspecialchars($item['mac-address'] ?? '-') ?></span>
</div>
</td>
<td>
<div class="text-sm text-foreground"><?= htmlspecialchars($item['address'] ?? '-') ?></div>
</td>
<td>
<div class="text-sm text-foreground"><?= htmlspecialchars($item['to-address'] ?? '-') ?></div>
</td>
<td>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-accents-2 text-accents-6">
<?= htmlspecialchars($item['server'] ?? '-') ?>
</span>
</td>
<td>
<div class="text-sm text-accents-5 italic"><?= htmlspecialchars($item['comment'] ?? '-') ?></div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> hosts
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<script>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '', server: '' };
this.init();
}
init() {
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
document.getElementById('filter-server').addEventListener('change', (e) => {
this.filters.server = e.target.value;
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const svr = row.dataset.server || '';
const mac = row.dataset.mac || '';
const addr = row.dataset.address || '';
const cmt = row.dataset.comment || '';
if (this.filters.server && svr !== this.filters.server) return false;
if (this.filters.search) {
if (!mac.includes(this.filters.search) && !addr.includes(this.filters.search) && !cmt.includes(this.filters.search)) return false;
}
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select').forEach(s => new CustomSelect(s));
}
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>

View File

@@ -0,0 +1,354 @@
<?php
$title = "Scheduler";
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight" data-i18n="system_menu.scheduler">Scheduler</h1>
<p class="text-accents-5"><span data-i18n="system_tools.scheduler_subtitle">Manage RouterOS automated tasks for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button>
<button onclick="openModal('addModal')" 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>
</button>
</div>
</div>
<?php if (isset($error) && $error): ?>
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 flex items-center">
<i data-lucide="alert-circle" class="w-5 h-5 mr-3"></i>
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center">
<!-- Search -->
<div class="input-group md:w-64 z-10">
<div class="input-icon">
<i data-lucide="search" class="h-4 w-4"></i>
</div>
<input type="text" id="global-search" class="form-input-search w-full" placeholder="Search task name..." data-i18n-placeholder="common.table.search_placeholder">
</div>
</div>
<div class="table-container">
<table class="table-glass" id="scheduler-table">
<thead>
<tr>
<th data-sort="name" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="system_tools.table_name">Name</th>
<th data-i18n="system_tools.interval">Interval</th>
<th data-i18n="system_tools.next_run">Next Run</th>
<th data-sort="status" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="system_tools.status">Status</th>
<th class="text-right" data-i18n="common.actions">Actions</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (!empty($schedulers) && is_array($schedulers)): ?>
<?php foreach ($schedulers as $task):
$status = ($task['disabled'] === 'true') ? 'disabled' : 'enabled';
?>
<tr class="table-row-item"
data-name="<?= strtolower($task['name']) ?>"
data-status="<?= $status ?>">
<td>
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($task['name']) ?></div>
<div class="text-xs text-accents-5 truncate max-w-[200px]"><?= htmlspecialchars($task['on-event']) ?></div>
</td>
<td class="text-sm text-accents-5"><?= htmlspecialchars($task['interval']) ?></td>
<td class="text-sm text-accents-5"><?= htmlspecialchars($task['next-run'] ?? '-') ?></td>
<td>
<?php if ($task['disabled'] === 'true'): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-accents-2 text-accents-5" data-i18n="system_tools.disabled">Disabled</span>
<?php else: ?>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" data-i18n="system_tools.enabled">Enabled</span>
<?php endif; ?>
</td>
<td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal">
<button onclick="editTask(<?= htmlspecialchars(json_encode($task)) ?>)" class="btn-icon" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i>
</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">
<input type="hidden" name="id" value="<?= $task['.id'] ?>">
<button type="submit" class="btn-icon-danger" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> tasks
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
</div>
<!-- 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>
class TableManager {
constructor(rows, itemsPerPage = 10) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '' };
this.init();
}
init() {
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
});
}
update() {
this.filteredRows = this.allRows.filter(row => {
const name = row.dataset.name || '';
if (this.filters.search && !name.includes(this.filters.search)) return false;
return true;
});
this.render();
}
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
function openModal(id) {
const modal = document.getElementById(id);
const content = modal.querySelector('.modal-content');
modal.classList.remove('hidden');
// Force reflow
void modal.offsetWidth;
modal.classList.remove('opacity-0');
content.classList.remove('scale-95', 'opacity-0');
content.classList.add('scale-100', 'opacity-100');
}
function closeModal(id) {
const modal = document.getElementById(id);
const content = modal.querySelector('.modal-content');
modal.classList.add('opacity-0');
content.classList.remove('scale-100', 'opacity-100');
content.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
}, 300); // Match duration-300
}
function editTask(task) {
document.getElementById('edit_id').value = task['.id'];
document.getElementById('edit_name').value = task['name'];
document.getElementById('edit_interval').value = task['interval'];
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');
}
document.addEventListener('DOMContentLoaded', () => {
new TableManager(document.querySelectorAll('.table-row-item'), 10);
});
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

24
composer.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "dyzulk/mivo",
"description": "MIVO - Modern Mikrotik Voucher Management System",
"type": "project",
"license": "MIT",
"authors": [
{
"name": "DyzulkDev",
"email": "dev@dyzulk.com"
}
],
"require": {
"php": "^8.0",
"ext-sqlite3": "*",
"ext-openssl": "*",
"ext-json": "*"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"minimum-stability": "stable"
}

53
deploy.ps1 Normal file
View File

@@ -0,0 +1,53 @@
$ErrorActionPreference = "Stop"
# Configuration
$RemotePath = "/www/wwwroot/app.mivo.dyzulk.com"
Write-Host "Starting Deployment to app.mivo.dyzulk.com..." -ForegroundColor Green
# 1. Build Assets
Write-Host "Building assets..." -ForegroundColor Cyan
cmd /c "npm run build"
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed!"
}
# 2. Create Archive
Write-Host "Creating deployment package..." -ForegroundColor Cyan
# Excluding potential garbage
$excludeParams = @("--exclude", "node_modules", "--exclude", ".git", "--exclude", ".github", "--exclude", "temp_debug", "--exclude", "deploy.ps1", "--exclude", "*.tar.gz")
tar -czf deploy_package.tar.gz @excludeParams app public routes mivo src package.json
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create archive!"
}
# 3. Upload
Write-Host "Uploading to server ($RemotePath)..." -ForegroundColor Cyan
scp deploy_package.tar.gz "aapanel:$RemotePath/"
if ($LASTEXITCODE -ne 0) {
Write-Error "SCP upload failed!"
}
# 4. Extract and Cleanup on Server
Write-Host "Extracting and configuring permissions..." -ForegroundColor Cyan
# Commands:
# 1. cd to remote path
# 2. Extract
# 3. Set ownership to www:www
# 4. Set mivo executable
# 5. Set public folder to 755 (Laravel recommendation)
# 6. Cleanup archive
$remoteCommands = "cd $RemotePath && tar -xzf deploy_package.tar.gz && chown -R www:www . && chmod +x mivo && chmod -R 755 public && rm deploy_package.tar.gz"
ssh aapanel $remoteCommands
if ($LASTEXITCODE -ne 0) {
Write-Error "Remote deployment failed!"
}
# 5. Local Cleanup
Write-Host "Cleaning up local package..." -ForegroundColor Cyan
if (Test-Path deploy_package.tar.gz) {
Remove-Item deploy_package.tar.gz
}
Write-Host "Deployment successfully completed!" -ForegroundColor Green

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3.8'
services:
app:
build: .
container_name: mivo_app
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./app/Database:/var/www/html/app/Database
- ./.env:/var/www/html/.env
environment:
- APP_ENV=production

27
docker/nginx.conf Normal file
View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Deny access to . files
location ~ /\. {
deny all;
}
# Deny access to sensitive folders explicitly if root wasn't public (safety net)
location ~ ^/(app|docker|docs|routes|src|temp_debug)/ {
deny all;
}
}

22
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,22 @@
[supervisord]
nodaemon=true
logfile=/dev/null
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=docker-php-entrypoint php-fpm
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
autorestart=true
[program:nginx]
command=nginx -g 'daemon off;'
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
autorestart=true

125
docs/INSTALLATION.md Normal file
View File

@@ -0,0 +1,125 @@
# MIVO Installation Guide
This guide covers installation on various platforms. MIVO is designed to be lightweight and runs on almost any PHP-capable server.
## 📋 General Requirements
* **PHP**: 8.0 or higher
* **Extensions**: `sqlite3`, `openssl`, `mbstring`, `json`
* **Database**: SQLite (File based, no server needed)
---
## 🐋 Docker (Recommended)
The easiest way to run MIVO.
1. **Build & Run**
```bash
docker-compose up -d --build
```
2. **Access**
Go to `http://localhost:8080`
*Note: The database is persisted in `app/Database` via volumes.*
---
## 🪶 Apache / OpenLiteSpeed
1. **Document Root**: Set your web server's document root to the `public/` folder.
2. **Rewrite Rules**: Ensure `mod_rewrite` is enabled. MIVO includes a `.htaccess` file in `public/` that handles URL routing automatically.
3. **Permissions**: Ensure the web server user (e.g., `www-data`) has **write** access to:
* `app/Database/` (directory and file)
* `app/Config/` (if using installer)
* `.env` file
---
## 🟢 Nginx
Nginx does not read `.htaccess`. Use this configuration block in your `server` block:
```nginx
server {
listen 80;
server_name your-domain.com;
root /path/to/mivo/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # Adjust version
}
location ~ /\.ht {
deny all;
}
}
```
---
## 🪟 IIS (Windows)
1. **Document Root**: Point the site to the `public/` folder.
2. **Web Config**: A `web.config` file has been provided in `public/` to handle URL Rewriting.
3. **Requirements**: Ensure **URL Rewrite Module 2.0** is installed on IIS.
---
## 📱 STB / Android (Awebserver / Termux)
### Awebserver
1. Copy the MIVO files to `/htdocs`.
2. Point the document root to `public` if supported, or access via `http://localhost:8080/public`.
3. Ensure PHP version is compatible.
### Termux
1. Install PHP: `pkg install php`
2. Navigate to MIVO directory: `cd mivo`
3. Use the built-in server:
```bash
php mivo serve --host=0.0.0.0 --port=8080
```
4. Access via browser.
---
---
## 🌐 Shared Hosting (cPanel / DirectAdmin)
Most shared hosting uses Apache or OpenLiteSpeed, which is fully compatible.
1. **Upload Files**: Upload the MIVO files to `public_html/mivo` (or a subdomain folder).
2. **Point Domain**:
* **Recommended**: Go to "Domains" or "Subdomains" in cPanel and set the **Document Root** to point strictly to the `public/` folder (e.g., `public_html/mivo/public`).
* **Alternative**: If you cannot change Document Root, you can move contents of `public/` to the root `public_html` and move `app/`, `routes/`, etc. one level up (not recommended for security).
3. **PHP Version**: Select PHP 8.0+ in "Select PHP Version" menu.
4. **Extensions**: Ensure `sqlite3` and `fileinfo` are checked.
---
## 🎛️ aaPanel (VPS)
1. **Create Website**: Add site -> PHP-8.x.
2. **Site Directory**:
* Set **Running Directory** (bukan Site Directory) to `/public`.
* Uncheck "Anti-XSS" (sometimes blocks config saving).
3. **URL Rewrite**: Select `thinkphp` or `laravel` template (compatible) OR just use the Nginx config provided above.
4. **Permissions**: Chown `www` user to the site directory.
---
## ☁️ PaaS Cloud (Railway / Render / Heroku)
**WARNING**: MIVO uses SQLite (File Database). Most PaaS cloud have **Ephemeral Filesytem** (Reset on restart).
* **Requirement**: You MUST mount a **Persistent Volume/Disk**.
* **Mount Path**: Mount your volume to `/var/www/html/app/Database` (or wherever you put MIVO).
* **Docker**: Use the Docker deployment method, it works natively on these platforms.
---
## 📥 Post-Installation
After setting up the server:
1. Copy `.env.example` to `.env`.
2. Run `php mivo install` OR access `/install` in your browser.

Some files were not shown because too many files have changed in this diff Show More