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