10 Commits

678 changed files with 26042 additions and 76934 deletions

View File

@@ -1,5 +1,6 @@
.git .git
.gitignore .gitignore
.github
.env .env
node_modules node_modules
deploy_package.tar.gz deploy_package.tar.gz

View File

@@ -1,6 +1,6 @@
APP_NAME=MIVO APP_NAME=MIVO
APP_ENV=production APP_ENV=production
APP_KEY=mikhmonv3remake_secret_key_32bytes APP_KEY=mivo_official_secret_key_32bytes
APP_DEBUG=true APP_DEBUG=true
# Database # Database

9
.github/release_template.md vendored Normal file
View File

@@ -0,0 +1,9 @@
MIVO is a Modern, Lightweight, and Efficient. Built for low-end devices with premium UX.
## Installation
For the best experience, we recommend using **Docker**.
[Read the full Docker Installation Guide](https://mivodev.github.io/docs/guide/docker)
## Notes
- Ensure your server runs **PHP 8.0+** with `sqlite3` extension enabled.
- Default installation will guide you to create an Admin account.

View File

@@ -0,0 +1,97 @@
const { GoogleGenerativeAI } = require("@google/generative-ai");
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
// Configuration
const API_KEY = process.env.GEMINI_API_KEY;
const MODEL_NAME = process.env.GEMINI_MODEL || "gemini-2.5-flash";
const VERSION_TAG = process.argv[2]; // e.g., v1.2.0
// Fix for Windows: Avoid 2>/dev/null, handle error in try-catch block instead
const PREVIOUS_TAG_CMD = `git describe --abbrev=0 --tags ${VERSION_TAG}~1`;
if (!API_KEY) {
console.error("Error: GEMINI_API_KEY is not set.");
process.exit(1);
}
if (!VERSION_TAG) {
console.error("Error: Version tag must be provided as the first argument.");
process.exit(1);
}
async function run() {
try {
console.log(`Generating release notes for ${VERSION_TAG} using ${MODEL_NAME}...`);
// 1. Get Previous Tag
let previousTag;
try {
previousTag = execSync(PREVIOUS_TAG_CMD).toString().trim();
} catch (e) {
console.log("No previous tag found, assuming first release.");
previousTag = execSync("git rev-list --max-parents=0 HEAD").toString().trim();
}
console.log(`Comparing from ${previousTag} to ${VERSION_TAG}`);
// 2. Get Commit Messages
const commits = execSync(`git log ${previousTag}..${VERSION_TAG} --pretty=format:"- %s (%h)" --no-merges`).toString();
if (!commits) {
console.log("No commits found between tags.");
return;
}
// 3. Generate Content with Gemini
const genAI = new GoogleGenerativeAI(API_KEY);
const model = genAI.getGenerativeModel({ model: MODEL_NAME });
const prompt = `
You are a release note generator for a software project named 'Mivo'.
Here are the commits for the new version ${VERSION_TAG}:
${commits}
Please generate a clean, professional release note in Markdown format.
Strict Rules:
1. **NO EMOJIS**: Do not use any emojis in headers, bullet points, or text.
2. **Structure**: Group changes strictly into these headers (if applicable):
- ### Features
- ### Bug Fixes
- ### Improvements
- ### Maintenance
3. **Format**: Use simple bullet points (-) for each item.
4. **Content**: Keep it concise but descriptive. Do not mention 'Merge pull request' commits.
5. **Header**: Start with a simple header: "# Release Notes ${VERSION_TAG}"
6. **Output**: Output ONLY the markdown content.
`;
const result = await model.generateContent(prompt);
const response = await result.response;
const text = response.text();
// 4. Read Template (Optional) and Merge
// For now, we just output the AI text. You can append this to a template if needed.
// Write to file
const outputPath = path.join(process.cwd(), ".github", "release_notes.md");
fs.writeFileSync(outputPath, text);
console.log(`Release notes generated at ${outputPath}`);
console.log(text);
// Export for GitHub Actions
const githubOutput = process.env.GITHUB_OUTPUT;
if (githubOutput) {
// Multiline string for GitHub Output
fs.appendFileSync(githubOutput, `RELEASE_NOTES<<EOF\n${text}\nEOF\n`);
}
} catch (error) {
console.error("Failed to generate release notes:", error);
process.exit(1);
}
}
run();

View File

@@ -1,55 +0,0 @@
name: Deploy Documentation
on:
push:
branches: [ main ]
paths:
- 'docs/**'
- '.github/workflows/deploy-docs.yml'
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 // fetch all history for lastUpdated
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install Dependencies
run: npm ci
- name: Build with VitePress
run: npm run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -10,9 +10,12 @@ on:
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
REGISTRY: docker.io REGISTRY: ghcr.io
# github.repository as <account>/<repo> # github.repository as <account>/<repo>
IMAGE_NAME: dyzulk/mivo IMAGE_NAME: ${{ github.repository }}
# Map secrets to env for availability in 'if' conditions
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
jobs: jobs:
build: build:
@@ -30,13 +33,21 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
# Login against a Docker registry except on PR # Login against GHCR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login against Docker Hub (Optional fallback if secrets exist)
- name: Log into Docker Hub
if: github.event_name != 'pull_request' && env.DOCKER_USERNAME != ''
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -46,13 +57,12 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
docker.io/${{ env.IMAGE_NAME }}
tags: | tags: |
# Branch main -> dyzulk/mivo:edge
type=raw,value=edge,enable={{is_default_branch}} type=raw,value=edge,enable={{is_default_branch}}
# Tag v1.0.0 -> dyzulk/mivo:1.0.0
type=ref,event=tag type=ref,event=tag
# Tag v1.0.0 -> dyzulk/mivo:latest
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)

View File

@@ -14,6 +14,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -32,11 +34,31 @@ jobs:
# Export source using git archive (respects .gitattributes) # Export source using git archive (respects .gitattributes)
git archive --format=tar HEAD | tar -x -C release_temp git archive --format=tar HEAD | tar -x -C release_temp
- name: Install Development Dependencies (for Build & AI)
run: npm install
- name: Build Localized Assets & Editor Bundle
run: |
npm run sync:assets
npm run build:editor
- name: Generate AI Release Notes
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
node .github/scripts/generate-release-notes.js ${{ github.ref_name }}
- name: Install Production Dependencies - name: Install Production Dependencies
run: | run: |
cd release_temp cd release_temp
composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs
- name: Copy Build Artifacts to Release
run: |
cp -r public/assets/vendor/ release_temp/public/assets/
mkdir -p release_temp/public/assets/js/vendor/
cp public/assets/js/vendor/editor.bundle.js release_temp/public/assets/js/vendor/
- name: Build Zip Artifact - name: Build Zip Artifact
run: | run: |
cd release_temp cd release_temp
@@ -46,7 +68,8 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: mivo-v${{ steps.get_version.outputs.VERSION }}.zip files: mivo-v${{ steps.get_version.outputs.VERSION }}.zip
generate_release_notes: true body_path: .github/release_notes.md
generate_release_notes: false
draft: false draft: false
prerelease: false prerelease: false
env: env:

9
.gitignore vendored
View File

@@ -22,14 +22,15 @@ Thumbs.db
# Secrets and Environment # Secrets and Environment
.env .env
# VitePress
docs/.vitepress/dist
docs/.vitepress/cache
# Build Scripts & Artifacts # Build Scripts & Artifacts
build_release.ps1 build_release.ps1
deploy.ps1 deploy.ps1
.github/release_notes.md
# User Uploads # User Uploads
/public/uploads/* /public/uploads/*
!/public/uploads/.gitignore !/public/uploads/.gitignore
# Plugins
/plugins/*
!/plugins/.gitkeep

1
CNAME
View File

@@ -1 +0,0 @@
docs.mivo.dyzulk.com

View File

@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/dyzulk/mivo/main/public/assets/img/logo.png" alt="MIVO Logo" width="200" /> <img src="https://raw.githubusercontent.com/mivodev/mivo/main/public/assets/img/logo.png" alt="MIVO Logo" width="200" />
</p> </p>
# MIVO (Mikrotik Voucher) Docker Image # MIVO (Mikrotik Voucher) Docker Image
@@ -24,7 +24,7 @@ docker run -d \
-e APP_ENV=production \ -e APP_ENV=production \
-v mivo_data:/var/www/html/app/Database \ -v mivo_data:/var/www/html/app/Database \
-v mivo_config:/var/www/html/.env \ -v mivo_config:/var/www/html/.env \
dyzulk/mivo:latest mivodev/mivo:latest
``` ```
Open your browser and navigate to `http://localhost:8080`. Open your browser and navigate to `http://localhost:8080`.
@@ -39,7 +39,7 @@ For a more permanent setup, use `docker-compose.yml`:
```yaml ```yaml
services: services:
mivo: mivo:
image: dyzulk/mivo:latest image: mivodev/mivo:latest
container_name: mivo container_name: mivo
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -111,4 +111,4 @@ If you find MIVO useful, please consider supporting its development. Your contri
[![SociaBuzz Tribe](https://img.shields.io/badge/SociaBuzz-Tribe-green?style=for-the-badge&logo=sociabuzz&logoColor=white)](https://sociabuzz.com/dyzulkdev/tribe) [![SociaBuzz Tribe](https://img.shields.io/badge/SociaBuzz-Tribe-green?style=for-the-badge&logo=sociabuzz&logoColor=white)](https://sociabuzz.com/dyzulkdev/tribe)
--- ---
*Created by DyzulkDev* *Created by MivoDev*

View File

@@ -30,15 +30,15 @@ MIVO is a next-generation **Mikrotik Voucher Management System** with a modern M
1. **Install via Composer** 1. **Install via Composer**
```bash ```bash
composer create-project dyzulk/mivo composer create-project mivodev/mivo
cd mivo cd mivo
``` ```
> **Alternative (Docker):** > **Alternative (Docker):**
> ```bash > ```bash
> docker pull dyzulk/mivo > docker pull mivodev/mivo
> ``` > ```
> *See [INSTALLATION.md](docs/INSTALLATION.md) for more tags.* > *See [DOCKER_README.md](DOCKER_README.md) for more tags.*
2. **Setup Environment** 2. **Setup Environment**
```bash ```bash
@@ -83,4 +83,4 @@ If you find MIVO useful, please consider supporting its development. Your contri
This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
--- ---
*Created by DyzulkDev* *Created by MivoDev*

View File

@@ -3,17 +3,17 @@ namespace App\Config;
class SiteConfig { class SiteConfig {
const APP_NAME = 'MIVO'; const APP_NAME = 'MIVO';
const APP_VERSION = 'v1.1.0'; const APP_VERSION = 'v1.2.1';
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher'; const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
const CREDIT_NAME = 'DyzulkDev'; const CREDIT_NAME = 'MivoDev';
const CREDIT_URL = 'https://dyzulk.com'; const CREDIT_URL = 'https://github.com/mivodev';
const YEAR = '2026'; const YEAR = '2026';
const REPO_URL = 'https://github.com/dyzulk/mivo'; const REPO_URL = 'https://github.com/mivodev/mivo';
// Security Keys // Security Keys
// Fetched from .env or fallback to default // Fetched from .env or fallback to default
public static function getSecretKey() { public static function getSecretKey() {
return getenv('APP_KEY') ?: 'mikhmonv3remake_secret_key_32bytes'; return getenv('APP_KEY') ?: 'mivo_official_secret_key_32bytes';
} }
const IS_DEV = true; // Still useful for code logic not relying on env yet, or can be refactored too. const IS_DEV = true; // Still useful for code logic not relying on env yet, or can be refactored too.

View File

@@ -44,7 +44,7 @@ class InstallController extends Controller {
Migrations::up(); Migrations::up();
// 2. Generate Key if default // 2. Generate Key if default
if (SiteConfig::getSecretKey() === 'mikhmonv3remake_secret_key_32bytes') { if (SiteConfig::getSecretKey() === 'mivo_official_secret_key_32bytes') {
$this->generateKey(); $this->generateKey();
} }
@@ -90,11 +90,11 @@ class InstallController extends Controller {
$envPath = ROOT . '/.env'; $envPath = ROOT . '/.env';
if (!file_exists($envPath)) { if (!file_exists($envPath)) {
// Check if SiteConfig has a manual override (legacy) // Check if SiteConfig has a manual override (legacy)
return SiteConfig::getSecretKey() !== 'mikhmonv3remake_secret_key_32bytes'; return SiteConfig::getSecretKey() !== 'mivo_official_secret_key_32bytes';
} }
$key = getenv('APP_KEY'); $key = getenv('APP_KEY');
$keyChanged = ($key && $key !== 'mikhmonv3remake_secret_key_32bytes'); $keyChanged = ($key && $key !== 'mivo_official_secret_key_32bytes');
try { try {
$db = Database::getInstance(); $db = Database::getInstance();

View File

@@ -191,10 +191,10 @@ class QuickPrintController extends Controller {
// Check if M or G // Check if M or G
// Simple logic for now, assuming raw if number, or passing string if Mikrotik accepts it (usually requires bytes) // 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. // 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. // For now, let's assume input is NUMBER in MB as per standard Mivo practice, OR generic string.
// We'll pass as is for strings, or multiply if strictly numeric? // We'll pass as is for strings, or multiply if strictly numeric?
// Let's rely on standard Mikrotik parsing if string passed, or convert. // Let's rely on standard Mikrotik parsing if string passed, or convert.
// Mikhmon v3 usually uses dropdown "MB/GB". // Mivo usually uses dropdown "MB/GB".
// Implementing simple conversion: // Implementing simple conversion:
$val = intval($package['data_limit']); $val = intval($package['data_limit']);
if (strpos(strtolower($package['data_limit']), 'g') !== false) { if (strpos(strtolower($package['data_limit']), 'g') !== false) {

View File

@@ -11,44 +11,89 @@ class ReportController extends Controller
{ {
public function index($session) public function index($session)
{ {
$configModel = new Config(); $data = $this->getSellingReportData($session);
$config = $configModel->getSession($session); if (!$data) {
if (!$config) {
header('Location: /'); header('Location: /');
exit; exit;
} }
return $this->view('reports/selling', $data);
}
public function sellingExport($session, $type)
{
$data = $this->getSellingReportData($session);
if (!$data) {
header('Content-Type: application/json');
echo json_encode(['error' => 'No data found']);
exit;
}
$report = $data['report'];
$exportData = [];
foreach ($report as $row) {
$exportData[] = [
'Date/Batch' => $row['date'],
'Status' => $row['status'] ?? '-',
'Qty (Stock)' => $row['count'],
'Used' => $row['realized_count'],
'Realized Income' => $row['realized_total'],
'Total Stock' => $row['total']
];
}
header('Content-Type: application/json');
echo json_encode($exportData);
exit;
}
private function getSellingReportData($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) return null;
$API = new RouterOSAPI(); $API = new RouterOSAPI();
$users = []; $users = [];
$profilePriceMap = [];
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) { 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"); $users = $API->comm("/ip/hotspot/user/print");
$profiles = $API->comm("/ip/hotspot/user/profile/print");
$API->disconnect(); $API->disconnect();
// Build Price Map from Profile Scripts
foreach ($profiles as $p) {
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
if (!empty($meta['price'])) {
$profilePriceMap[$p['name']] = intval($meta['price']);
}
}
} }
// Aggregate Data // Aggregate Data
$report = []; $report = [];
$totalIncome = 0; $totalIncome = 0;
$totalVouchers = 0; $totalVouchers = 0;
// Realized (Used) Metrics
$totalRealizedIncome = 0;
$totalUsedVouchers = 0;
foreach ($users as $user) { foreach ($users as $user) {
// Skip if no price // Smart Price Detection
if (empty($user['price']) || $user['price'] == '0') continue; $price = $this->detectPrice($user, $profilePriceMap);
if ($price <= 0) continue;
// Inject price back to user array for downstream logic
$user['price'] = $price;
// Determine Date from Comment // 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'; $date = 'Unknown Date';
$comment = $user['comment'] ?? ''; $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)) { if (!empty($comment)) {
$date = $comment; $date = $comment;
} }
@@ -57,28 +102,59 @@ class ReportController extends Controller
$report[$date] = [ $report[$date] = [
'date' => $date, 'date' => $date,
'count' => 0, 'count' => 0,
'total' => 0 'total' => 0,
'realized_total' => 0,
'realized_count' => 0
]; ];
} }
$price = intval($user['price']); $price = intval($user['price']);
// Check if Used
// Criteria: uptime != 0s OR bytes-out > 0 OR bytes-in > 0
$isUsed = false;
if ((isset($user['uptime']) && $user['uptime'] != '0s') ||
(isset($user['bytes-out']) && $user['bytes-out'] > 0)) {
$isUsed = true;
}
$report[$date]['count']++; $report[$date]['count']++;
$report[$date]['total'] += $price; $report[$date]['total'] += $price;
$totalIncome += $price; $totalIncome += $price;
$totalVouchers++; $totalVouchers++;
if ($isUsed) {
$report[$date]['realized_count']++;
$report[$date]['realized_total'] += $price;
$totalRealizedIncome += $price;
$totalUsedVouchers++;
}
} }
// Calculate Status for each batch
foreach ($report as &$row) {
if ($row['realized_count'] === 0) {
$row['status'] = 'New';
} elseif ($row['realized_count'] >= $row['count']) {
$row['status'] = 'Sold Out';
} else {
$row['status'] = 'Selling';
}
}
unset($row);
// Sort by key (Date/Comment) desc // Sort by key (Date/Comment) desc
krsort($report); krsort($report);
return $this->view('reports/selling', [ return [
'session' => $session, 'session' => $session,
'report' => $report, 'report' => $report,
'totalIncome' => $totalIncome, 'totalIncome' => $totalIncome,
'totalVouchers' => $totalVouchers, 'totalVouchers' => $totalVouchers,
'totalRealizedIncome' => $totalRealizedIncome,
'totalUsedVouchers' => $totalUsedVouchers,
'currency' => $config['currency'] ?? 'Rp' 'currency' => $config['currency'] ?? 'Rp'
]); ];
} }
public function resume($session) public function resume($session)
{ {
@@ -93,9 +169,18 @@ class ReportController extends Controller
$API = new RouterOSAPI(); $API = new RouterOSAPI();
$users = []; $users = [];
$profilePriceMap = [];
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) { if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$users = $API->comm("/ip/hotspot/user/print"); $users = $API->comm("/ip/hotspot/user/print");
$profiles = $API->comm("/ip/hotspot/user/profile/print");
$API->disconnect(); $API->disconnect();
foreach ($profiles as $p) {
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
if (!empty($meta['price'])) {
$profilePriceMap[$p['name']] = intval($meta['price']);
}
}
} }
// Initialize Aggregates // Initialize Aggregates
@@ -103,28 +188,69 @@ class ReportController extends Controller
$monthly = []; $monthly = [];
$yearly = []; $yearly = [];
$totalIncome = 0; $totalIncome = 0;
// Realized Metrics for Resume?
// Usually Resume is just general financial overview.
// We'll stick to Stock for now unless requested, as Resume mimics Mikhmon's logic closer.
// Or we can just calculate standard revenue based on Stock if that's what user expects for "Resume",
// OR we can add Realized. Let's keep Resume simple first, focus on Selling Report.
foreach ($users as $user) { foreach ($users as $user) {
if (empty($user['price']) || $user['price'] == '0') continue; $price = $this->detectPrice($user, $profilePriceMap);
if ($price <= 0) continue;
// Try to parse Date from Comment (Mikhmon format: mikhmon-10/25/2023 or just 10/25/2023) $user['price'] = $price;
// Try to parse Date from Comment
// Supported formats:
// - MM/DD/YYYY or MM.DD.YYYY (US)
// - DD-MM-YYYY (EU/ID)
// - YYYY-MM-DD (ISO)
// Regex explanations:
// 1. \b starts word boundary to avoid matching parts of batch IDs (e.g. 711-...)
// 2. We look for 3 groups of digits separated by / . or -
$comment = $user['comment'] ?? ''; $comment = $user['comment'] ?? '';
$dateObj = null; $dateObj = null;
// Simple parser: try to find MM/DD/YYYY if (preg_match('/\b(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})\b/', $comment, $matches)) {
if (preg_match('/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})/', $comment, $matches)) { // Heuristic: If 3rd part is year (4 digits or > 31), use it.
// Assuming MM/DD/YYYY based on typical Mikhmon, but could be DD-MM-YYYY // If 1st part > 12, it's likely Day (DD-MM-YYYY).
// Let's standardise on checking valid date. // Mivo Generator format often: MM.DD.YY or DD.MM.YY
// Standard Mikhmon V3 is MM/DD/YYYY.
$m = $matches[1]; $p1 = intval($matches[1]);
$d = $matches[2]; $p2 = intval($matches[2]);
$y = $matches[3]; $p3 = intval($matches[3]);
if (strlen($y) == 2) $y = '20' . $y;
$dateObj = \DateTime::createFromFormat('m/d/Y', "$m/$d/$y"); $year = $p3;
$month = $p1;
$day = $p2;
// Adjust 2-digit year
if ($year < 100) $year += 2000;
// Guess format
// If p1 > 12, it must be Day. (DD-MM-YYYY)
if ($p1 > 12) {
$day = $p1;
$month = $p2;
}
// Validate date
if (checkdate($month, $day, $year)) {
$dateObj = (new \DateTime())->setDate($year, $month, $day);
}
}
// Check for ISO YYYY-MM-DD
elseif (preg_match('/\b(\d{4})[\/.-](\d{1,2})[\/.-](\d{1,2})\b/', $comment, $matches)) {
if (checkdate($matches[2], $matches[3], $matches[1])) {
$dateObj = (new \DateTime())->setDate($matches[1], $matches[2], $matches[3]);
}
} }
// Fallback: If no date found in comment, maybe created at? // Fallback: If no date found -> "Unknown Date" in resume?
// Usually Mikhmon relies strictly on comment. // Resume requires Month/Year keys. If we can't parse date, we can't add to daily/monthly.
// We'll skip or add to "Unknown"?
// Current logic skips if !$dateObj
if (!$dateObj) continue; if (!$dateObj) continue;
$price = intval($user['price']); $price = intval($user['price']);
@@ -162,4 +288,38 @@ class ReportController extends Controller
'currency' => $config['currency'] ?? 'Rp' 'currency' => $config['currency'] ?? 'Rp'
]); ]);
} }
/**
* Smart Price Detection Logic
* Hierarchy:
* 1. Comment Override (p:5000)
* 2. Profile Script (Standard Profile)
* 3. Profile Name Fallback (50K) -- REMOVED loose number matching to avoid garbage data
*/
private function detectPrice($user, $profileMap)
{
$comment = $user['comment'] ?? '';
// 1. Comment Override (p:5000 or price:5000)
// Updated: Added \b to prevent matching "up-123" as "p-123"
if (preg_match('/\b(?:p|price)[:-]\s*(\d+)/i', $comment, $matches)) {
return intval($matches[1]);
}
// 2. Profile Script
$profile = $user['profile'] ?? 'default';
if (isset($profileMap[$profile])) {
return $profileMap[$profile];
}
// 3. Fallback: Parse Profile Name (Strict "K" notation only)
// Matches "5K", "5k" -> 5000
if (preg_match('/(\d+)k\b/i', $profile, $m)) {
return intval($m[1]) * 1000;
}
// DEPRECATED: Loose number matching caused garbage data (e.g. "up-311" -> 311)
return 0;
}
} }

View File

@@ -77,7 +77,7 @@ class SettingsController extends Controller {
$db = \App\Core\Database::getInstance(); $db = \App\Core\Database::getInstance();
$hash = password_hash($newPassword, PASSWORD_DEFAULT); $hash = password_hash($newPassword, PASSWORD_DEFAULT);
// Assuming we are updating the default 'admin' user or the currently logged in user // 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. // Original Mivo usually has one main user. Let's update 'admin' for now.
$db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]); $db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]);
\App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true); \App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true);
} }
@@ -431,4 +431,194 @@ class SettingsController extends Controller {
} }
header('Location: /settings/api-cors'); header('Location: /settings/api-cors');
} }
// --- Plugin Management ---
public function plugins() {
$pluginManager = new \App\Core\PluginManager();
// Since PluginManager loads everything in constructor/loadPlugins,
// we can just scan the directory to list them and check status (implied active for now)
$pluginsDir = ROOT . '/plugins';
$plugins = [];
if (is_dir($pluginsDir)) {
$folders = scandir($pluginsDir);
foreach ($folders as $folder) {
if ($folder === '.' || $folder === '..') continue;
$path = $pluginsDir . '/' . $folder;
if (is_dir($path) && file_exists($path . '/plugin.php')) {
// Try to read header from plugin.php
$content = file_get_contents($path . '/plugin.php', false, null, 0, 1024); // Read first 1KB
preg_match('/Plugin Name:\s*(.*)$/mi', $content, $nameMatch);
preg_match('/Version:\s*(.*)$/mi', $content, $verMatch);
preg_match('/Description:\s*(.*)$/mi', $content, $descMatch);
preg_match('/Author:\s*(.*)$/mi', $content, $authMatch);
$plugins[] = [
'id' => $folder,
'name' => trim($nameMatch[1] ?? $folder),
'version' => trim($verMatch[1] ?? '1.0.0'),
'description' => trim($descMatch[1] ?? '-'),
'author' => trim($authMatch[1] ?? '-'),
'path' => $path
];
}
}
}
return $this->view('settings/plugins', ['plugins' => $plugins]);
}
public function uploadPlugin() {
if (!isset($_FILES['plugin_file']) || $_FILES['plugin_file']['error'] !== UPLOAD_ERR_OK) {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
header('Location: /settings/plugins');
exit;
}
$file = $_FILES['plugin_file'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'zip') {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Only .zip files are allowed', [], true);
header('Location: /settings/plugins');
exit;
}
$zip = new \ZipArchive();
if ($zip->open($file['tmp_name']) === TRUE) {
$extractPath = ROOT . '/plugins/';
if (!is_dir($extractPath)) mkdir($extractPath, 0755, true);
// TODO: Better validation to prevent overwriting existing plugins without confirmation?
// For now, extraction overwrites.
// Validate content before extracting everything
// Check if zip has a root folder or just files
// Logic:
// 1. Extract to temp.
// 2. Find plugin.php
// 3. Move to plugins dir.
$tempExtract = sys_get_temp_dir() . '/mivo_plugin_' . uniqid();
if (!mkdir($tempExtract, 0755, true)) {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Failed to create temp dir', [], true);
header('Location: /settings/plugins');
exit;
}
$zip->extractTo($tempExtract);
$zip->close();
// Search for plugin.php
$pluginFile = null;
$pluginRoot = $tempExtract;
// Recursive iterator to find plugin.php (max depth 2 to avoid deep scanning)
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tempExtract));
foreach ($rii as $file) {
if ($file->isDir()) continue;
if ($file->getFilename() === 'plugin.php') {
$pluginFile = $file->getPathname();
$pluginRoot = dirname($pluginFile);
break;
}
}
if ($pluginFile) {
// Determine destination name
// If the immediate parent of plugin.php is NOT the temp dir, use that folder name.
// Else use the zip name.
$folderName = basename($pluginRoot);
if ($pluginRoot === $tempExtract) {
$folderName = pathinfo($_FILES['plugin_file']['name'], PATHINFO_FILENAME);
}
$dest = $extractPath . $folderName;
// Move/Copy
// Using helper or rename. Rename might fail across volumes (temp to project).
// Use custom recursive copy then delete temp.
$this->recurseCopy($pluginRoot, $dest);
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_installed', 'toasts.plugin_installed_desc', ['name' => $folderName], true);
} else {
\App\Helpers\FlashHelper::set('error', 'toasts.install_failed', 'toasts.invalid_plugin_desc', [], true);
}
// Cleanup
$this->recurseDelete($tempExtract);
} else {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.zip_open_failed_desc', [], true);
}
header('Location: /settings/plugins');
}
public function deletePlugin() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /settings/plugins');
exit;
}
$id = $_POST['plugin_id'] ?? '';
if (empty($id)) {
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID', [], true);
header('Location: /settings/plugins');
exit;
}
// Security check: validate id is just a folder name, no path traversal
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $id)) {
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID format', [], true);
header('Location: /settings/plugins');
exit;
}
$pluginDir = ROOT . '/plugins/' . $id;
if (is_dir($pluginDir)) {
$this->recurseDelete($pluginDir);
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_deleted', 'toasts.plugin_deleted_desc', [], true);
} else {
\App\Helpers\FlashHelper::set('error', 'common.error', 'Plugin directory not found', [], true);
}
header('Location: /settings/plugins');
exit;
}
// Helper for recursive copy (since rename/move_uploaded_file limit across partitions)
private function recurseCopy($src, $dst) {
$dir = opendir($src);
@mkdir($dst);
while(false !== ( $file = readdir($dir)) ) {
if (( $file != '.' ) && ( $file != '..' )) {
if ( is_dir($src . '/' . $file) ) {
$this->recurseCopy($src . '/' . $file,$dst . '/' . $file);
}
else {
copy($src . '/' . $file,$dst . '/' . $file);
}
}
}
closedir($dir);
}
private function recurseDelete($dir) {
if (!is_dir($dir)) return;
$scan = scandir($dir);
foreach ($scan as $file) {
if ($file == '.' || $file == '..') continue;
if (is_dir($dir . "/" . $file)) {
$this->recurseDelete($dir . "/" . $file);
} else {
unlink($dir . "/" . $file);
}
}
rmdir($dir);
}
} }

View File

@@ -45,7 +45,7 @@ class Console {
private function printBanner() { private function printBanner() {
echo "\n"; echo "\n";
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.1.0" . self::COLOR_RESET . "\n\n"; echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . \App\Config\SiteConfig::APP_VERSION . self::COLOR_RESET . "\n\n";
} }
private function commandServe($args) { private function commandServe($args) {
@@ -171,7 +171,7 @@ class Console {
if (file_exists($envPath)) { if (file_exists($envPath)) {
$envIds = parse_ini_file($envPath); $envIds = parse_ini_file($envPath);
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mikhmonv3remake_secret_key_32bytes') { if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mivo_official_secret_key_32bytes') {
$keyExists = true; $keyExists = true;
} }
} }

120
app/Core/Hooks.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
namespace App\Core;
class Hooks
{
/**
* @var array Stores all registered actions
*/
private static $actions = [];
/**
* @var array Stores all registered filters
*/
private static $filters = [];
/**
* Register a new action
*
* @param string $tag The name of the action hook
* @param callable $callback The function to call
* @param int $priority Lower numbers correspond to earlier execution
* @param int $accepted_args The number of arguments the function accepts
*/
public static function addAction($tag, $callback, $priority = 10, $accepted_args = 1)
{
self::$actions[$tag][$priority][] = [
'function' => $callback,
'accepted_args' => $accepted_args
];
}
/**
* Execute an action
*
* @param string $tag The name of the action hook
* @param mixed ...$args Optional arguments to pass to the callback
*/
public static function doAction($tag, ...$args)
{
if (empty(self::$actions[$tag])) {
return;
}
// Sort by priority
ksort(self::$actions[$tag]);
foreach (self::$actions[$tag] as $priority => $callbacks) {
foreach ($callbacks as $callbackData) {
call_user_func_array($callbackData['function'], array_slice($args, 0, $callbackData['accepted_args']));
}
}
}
/**
* Register a new filter
*
* @param string $tag The name of the filter hook
* @param callable $callback The function to call
* @param int $priority Lower numbers correspond to earlier execution
* @param int $accepted_args The number of arguments the function accepts
*/
public static function addFilter($tag, $callback, $priority = 10, $accepted_args = 1)
{
self::$filters[$tag][$priority][] = [
'function' => $callback,
'accepted_args' => $accepted_args
];
}
/**
* Apply filters to a value
*
* @param string $tag The name of the filter hook
* @param mixed $value The value to be filtered
* @param mixed ...$args Optional extra arguments
* @return mixed The filtered value
*/
public static function applyFilters($tag, $value, ...$args)
{
if (empty(self::$filters[$tag])) {
return $value;
}
// Sort by priority
ksort(self::$filters[$tag]);
foreach (self::$filters[$tag] as $priority => $callbacks) {
foreach ($callbacks as $callbackData) {
// Prepend value to args
$params = array_merge([$value], array_slice($args, 0, $callbackData['accepted_args'] - 1));
$value = call_user_func_array($callbackData['function'], $params);
}
}
return $value;
}
/**
* Check if any action has been registered for a hook.
*
* @param string $tag The name of the action hook.
* @return bool True if action exists, false otherwise.
*/
public static function hasAction($tag)
{
return isset(self::$actions[$tag]);
}
/**
* Check if any filter has been registered for a hook.
*
* @param string $tag The name of the filter hook.
* @return bool True if filter exists, false otherwise.
*/
public static function hasFilter($tag)
{
return isset(self::$filters[$tag]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Core;
class PluginManager
{
/**
* @var string Path to plugins directory
*/
private $pluginsDir;
/**
* @var array List of active plugins
*/
private $activePlugins = [];
public function __construct()
{
$this->pluginsDir = dirname(__DIR__, 2) . '/plugins'; // Root/plugins
}
/**
* Load all active plugins
*/
public function loadPlugins()
{
// Ensure plugins directory exists
if (!is_dir($this->pluginsDir)) {
return;
}
// 1. Get List of Active Plugins (For now, we load ALL folders as active)
// TODO: Implement database/config check for active status
$plugins = scandir($this->pluginsDir);
foreach ($plugins as $pluginName) {
if ($pluginName === '.' || $pluginName === '..') {
continue;
}
$pluginPath = $this->pluginsDir . '/' . $pluginName;
// Check if it is a directory and has specific plugin file
if (is_dir($pluginPath) && file_exists($pluginPath . '/plugin.php')) {
$this->loadPlugin($pluginName, $pluginPath);
}
}
// Fire 'plugins_loaded' action after all plugins are loaded
Hooks::doAction('plugins_loaded');
}
/**
* Load a single plugin
*
* @param string $name Plugin folder name
* @param string $path Full path to plugin directory
*/
private function loadPlugin($name, $path)
{
try {
require_once $path . '/plugin.php';
$this->activePlugins[] = $name;
} catch (\Exception $e) {
error_log("Failed to load plugin [$name]: " . $e->getMessage());
}
}
/**
* Get list of loaded plugins
*
* @return array
*/
public function getActivePlugins()
{
return $this->activePlugins;
}
}

View File

@@ -92,12 +92,21 @@ class Router {
} }
public function dispatch($uri, $method) { public function dispatch($uri, $method) {
// Fire hook to allow plugins to register routes
\App\Core\Hooks::doAction('router_init', $this);
$path = parse_url($uri, PHP_URL_PATH); $path = parse_url($uri, PHP_URL_PATH);
// Handle subdirectory // Handle subdirectory (SKIP for PHP Built-in Server to avoid SCRIPT_NAME issues)
$scriptName = dirname($_SERVER['SCRIPT_NAME']); if (php_sapi_name() !== 'cli-server') {
if (strpos($path, $scriptName) === 0) { $scriptName = dirname($_SERVER['SCRIPT_NAME']);
$path = substr($path, strlen($scriptName)); // Normalize backslashes (Windows)
$scriptName = str_replace('\\', '/', $scriptName);
// Ensure we don't strip root slash
if ($scriptName !== '/' && strpos($path, $scriptName) === 0) {
$path = substr($path, strlen($scriptName));
}
} }
$path = $this->normalizePath($path); $path = $this->normalizePath($path);

View File

@@ -5,7 +5,7 @@ namespace App\Helpers;
class HotspotHelper class HotspotHelper
{ {
/** /**
* Parse profile on-login script metadata (Mikhmon format) * Parse profile on-login script metadata (Standard format)
* Format: :put (",mode,price,validity,selling_price,lock_user,") * Format: :put (",mode,price,validity,selling_price,lock_user,")
*/ */
public static function parseProfileMetadata($script) { public static function parseProfileMetadata($script) {

View File

@@ -40,6 +40,6 @@ class LanguageHelper
} }
} }
return $languages; return \App\Core\Hooks::applyFilters('get_available_languages', $languages);
} }
} }

View File

@@ -46,13 +46,13 @@ class TemplateHelper {
'{{ip_address}}' => '192.168.88.254', '{{ip_address}}' => '192.168.88.254',
'{{mac_address}}' => 'AA:BB:CC:DD:EE:FF', '{{mac_address}}' => 'AA:BB:CC:DD:EE:FF',
'{{comment}}' => 'Thank You', '{{comment}}' => 'Thank You',
'{{copyright}}' => 'Mikhmon', '{{copyright}}' => 'Mivo',
]; ];
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content); $content = str_replace(array_keys($dummyData), array_values($dummyData), $content);
// QR Code replacement // QR Code replacement - Using canvas for client-side rendering with QRious
$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); $content = preg_replace('/\{\{\s*qrcode\s*(.*?)\s*\}\}/i', '<canvas class="qrcode-placeholder" data-options=\'$1\' style="width:80px;height:80px;display:inline-block;"></canvas>', $content);
return $content; return $content;
} }
@@ -69,6 +69,7 @@ class TemplateHelper {
body { display: flex; align-items: center; justify-content: center; background-color: transparent; } 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); } #wrapper { display: inline-block; transform-origin: center center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
</style> </style>
<script src="/assets/js/qrious.min.js"></script>
</head> </head>
<body> <body>
<div id="wrapper">' . $mockContent . '</div> <div id="wrapper">' . $mockContent . '</div>
@@ -76,6 +77,48 @@ class TemplateHelper {
window.addEventListener("load", () => { window.addEventListener("load", () => {
const wrap = document.getElementById("wrapper"); const wrap = document.getElementById("wrapper");
if(!wrap) return; if(!wrap) return;
// Render QR Codes
document.querySelectorAll(".qrcode-placeholder").forEach(canvas => {
const optionsStr = canvas.dataset.options || "";
const options = {};
// Robust parser for "fg=red bg=#fff size=100" format
const regex = /([a-z]+)=([^ \t\r\n\f\v"]+|"[^"]*"|\'[^\']*\')/gi;
let match;
while ((match = regex.exec(optionsStr)) !== null) {
let key = match[1].toLowerCase();
let val = match[2].replace(/["\']/g, "");
options[key] = val;
}
new QRious({
element: canvas,
value: "http://hotspot.lan/login?username=u-5829&password=5912",
size: parseInt(options.size) || 100,
foreground: options.fg || "#000000",
backgroundAlpha: 0,
level: "M"
});
// Handle styles via CSS for better compatibility with rounding and padding
if (options.size) {
canvas.style.width = options.size + "px";
canvas.style.height = options.size + "px";
}
if (options.bg) {
canvas.style.backgroundColor = options.bg;
}
if (options.padding) {
canvas.style.padding = options.padding + "px";
}
if (options.rounded) {
canvas.style.borderRadius = options.rounded + "px";
}
});
const updateScale = () => { const updateScale = () => {
const w = wrap.offsetWidth; const w = wrap.offsetWidth;

View File

@@ -326,6 +326,30 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
setInterval(fetchTraffic, reloadInterval); setInterval(fetchTraffic, reloadInterval);
fetchTraffic(); fetchTraffic();
}); });
// Localization Support
const updateChartLabels = () => {
if (window.i18n && window.i18n.isLoaded) {
const rxLabel = window.i18n.t('dashboard.rx_download');
const txLabel = window.i18n.t('dashboard.tx_upload');
// Only update if changed
if (chart.data.datasets[0].label !== rxLabel || chart.data.datasets[1].label !== txLabel) {
chart.data.datasets[0].label = rxLabel;
chart.data.datasets[1].label = txLabel;
chart.update('none');
}
}
};
// Listen for language changes
if (window.Mivo) {
window.Mivo.on('languageChanged', updateChartLabels);
}
window.addEventListener('languageChanged', updateChartLabels);
// Try initial update after a short delay to ensure i18n is ready if race condition
setTimeout(updateChartLabels, 500);
}); });
</script> </script>

View File

@@ -4,7 +4,7 @@
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8">
<div> <div>
<h1 class="text-3xl font-bold tracking-tight">Design System</h1> <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> <p class="text-accents-5">Component library and style guide for Mivo.</p>
</div> </div>
<div class="flex gap-2"> <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.remove('dark')" class="btn bg-gray-200 text-gray-800">Light</button>

View File

@@ -3,8 +3,8 @@
<div class="w-full max-w-4xl mx-auto py-8 md:py-16 px-4 sm:px-6 text-center"> <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="mb-8 flex justify-center">
<div class="h-16 w-16 bg-transparent rounded-full flex items-center 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.svg" alt="Mivo 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"> <img src="/assets/img/logo-m-dark.svg" alt="Mivo Logo" class="h-16 w-auto hidden dark:block">
</div> </div>
</div> </div>

View File

@@ -9,15 +9,15 @@
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200 py-8 text-center space-y-4"> <footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200 py-8 text-center space-y-4">
<!-- Links Row --> <!-- Links Row -->
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5"> <div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2"> <a href="https://mivodev.github.io" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
<i data-lucide="book-open" class="w-4 h-4"></i> <i data-lucide="book-open" class="w-4 h-4"></i>
<span>Docs</span> <span>Docs</span>
</a> </a>
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2"> <a href="https://github.com/mivodev/mivo/discussions" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
<i data-lucide="message-circle" class="w-4 h-4"></i> <i data-lucide="message-circle" class="w-4 h-4"></i>
<span>Community</span> <span>Community</span>
</a> </a>
<a href="https://github.com/dyzulk/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2"> <a href="https://github.com/mivodev/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
<i data-lucide="github" class="w-4 h-4"></i> <i data-lucide="github" class="w-4 h-4"></i>
<span>Repo</span> <span>Repo</span>
</a> </a>
@@ -306,5 +306,6 @@
}, 300); // 300ms delay to prevent accidental closure }, 300); // 300ms delay to prevent accidental closure
} }
</script> </script>
<?php \App\Core\Hooks::doAction('mivo_footer'); ?>
</body> </body>
</html> </html>

View File

@@ -1,14 +1,14 @@
<footer class="mt-auto py-8 text-center space-y-4"> <footer class="mt-auto py-8 text-center space-y-4">
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5"> <div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2"> <a href="https://mivodev.github.io" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
<i data-lucide="book-open" class="w-4 h-4"></i> <i data-lucide="book-open" class="w-4 h-4"></i>
<span>Docs</span> <span>Docs</span>
</a> </a>
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2"> <a href="https://github.com/mivodev/mivo/discussions" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
<i data-lucide="message-circle" class="w-4 h-4"></i> <i data-lucide="message-circle" class="w-4 h-4"></i>
<span>Community</span> <span>Community</span>
</a> </a>
<a href="https://github.com/dyzulk/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2"> <a href="https://github.com/mivodev/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
<i data-lucide="github" class="w-4 h-4"></i> <i data-lucide="github" class="w-4 h-4"></i>
<span>Repo</span> <span>Repo</span>
</a> </a>
@@ -79,5 +79,6 @@
}); });
<?php endif; ?> <?php endif; ?>
</script> </script>
<?php \App\Core\Hooks::doAction('mivo_footer'); ?>
</body> </body>
</html> </html>

View File

@@ -19,8 +19,8 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
<!-- Tailwind CSS (Local) --> <!-- Tailwind CSS (Local) -->
<link rel="stylesheet" href="/assets/css/styles.css"> <link rel="stylesheet" href="/assets/css/styles.css">
<!-- Flag Icons --> <!-- Flag Icons (Local) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" /> <link rel="stylesheet" href="/assets/vendor/flag-icons/css/flag-icons.min.css" />
<style> <style>
@@ -114,6 +114,8 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
} }
</style> </style>
<?php \App\Core\Hooks::doAction('mivo_head'); ?>
</head> </head>
<body class="flex flex-col min-h-screen bg-background text-foreground anti-aliased relative"> <body class="flex flex-col min-h-screen bg-background text-foreground anti-aliased relative">
<!-- Background Elements (Global Sci-Fi Grid) --> <!-- Background Elements (Global Sci-Fi Grid) -->

View File

@@ -29,6 +29,7 @@
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
} }
</script> </script>
<?php \App\Core\Hooks::doAction('mivo_head'); ?>
</head> </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"> <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">

View File

@@ -53,8 +53,9 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
<?php <?php
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages(); $languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
foreach ($languages as $lang): foreach ($languages as $lang):
$pathArg = isset($lang['path']) ? "', '" . $lang['path'] : "";
?> ?>
<button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang"> <button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?><?= $pathArg ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang">
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span> <span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
<span><?= $lang['name'] ?></span> <span><?= $lang['name'] ?></span>
</button> </button>
@@ -123,8 +124,10 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
<span class="text-xs font-bold text-accents-4 uppercase tracking-wider">Select Language</span> <span class="text-xs font-bold text-accents-4 uppercase tracking-wider">Select Language</span>
</div> </div>
<div class="flex items-center gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide snap-x"> <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): ?> <?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"> $pathArg = isset($lang['path']) ? "', '" . $lang['path'] : "";
?>
<button onclick="changeLanguage('<?= $lang['code'] ?><?= $pathArg ?>')" 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="fi fi-<?= $lang['flag'] ?> rounded-full shadow-sm"></span>
<span class="whitespace-nowrap"><?= $lang['name'] ?></span> <span class="whitespace-nowrap"><?= $lang['name'] ?></span>
</button> </button>

View File

@@ -390,21 +390,21 @@ $getInitials = function($name) {
</div> </div>
<!-- Docs --> <!-- Docs -->
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5"> <a href="https://mivodev.github.io" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
<i data-lucide="book-open" class="w-4 h-4"></i> <i data-lucide="book-open" class="w-4 h-4"></i>
<span data-i18n="sidebar.docs">Documentation</span> <span data-i18n="sidebar.docs">Documentation</span>
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i> <i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
</a> </a>
<!-- Community --> <!-- Community -->
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5"> <a href="https://github.com/mivodev/mivo/discussions" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
<i data-lucide="message-circle" class="w-4 h-4"></i> <i data-lucide="message-circle" class="w-4 h-4"></i>
<span data-i18n="sidebar.community">Community</span> <span data-i18n="sidebar.community">Community</span>
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i> <i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
</a> </a>
<!-- Repo --> <!-- Repo -->
<a href="https://github.com/dyzulk/mivo" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5"> <a href="https://github.com/mivodev/mivo" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
<i data-lucide="github" class="w-4 h-4"></i> <i data-lucide="github" class="w-4 h-4"></i>
<span data-i18n="sidebar.repo">Repository</span> <span data-i18n="sidebar.repo">Repository</span>
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i> <i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>

View File

@@ -14,6 +14,7 @@ $menu = [
['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'], ['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'],
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'], ['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'], ['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
['label' => 'plugins_title', 'url' => '/settings/plugins', '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"> <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">

View File

@@ -26,88 +26,92 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<!-- Daily Tab --> <!-- Daily Tab -->
<div id="content-daily" class="tab-content"> <div id="content-daily" class="tab-content">
<div class="table-container"> <table class="table-glass" id="table-daily">
<table class="table-glass"> <thead>
<thead> <tr>
<tr> <th data-i18n="reports.date">Date</th>
<th data-i18n="reports.date">Date</th> <th class="text-right" data-i18n="reports.total">Total</th>
<th class="text-right" data-i18n="reports.total">Total</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> <?php foreach ($daily as $date => $total): ?>
<?php foreach ($daily as $date => $total): ?> <tr>
<tr> <td><?= $date ?></td>
<td><?= $date ?></td> <td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td> </tr>
</tr> <?php endforeach; ?>
<?php endforeach; ?> </tbody>
</tbody> </table>
</table>
</div>
</div> </div>
<!-- Monthly Tab --> <!-- Monthly Tab -->
<div id="content-monthly" class="tab-content hidden"> <div id="content-monthly" class="tab-content hidden">
<div class="table-container"> <table class="table-glass" id="table-monthly">
<table class="table-glass"> <thead>
<thead> <tr>
<tr> <th data-i18n="reports.month">Month</th>
<th data-i18n="reports.month">Month</th> <th class="text-right" data-i18n="reports.total">Total</th>
<th class="text-right" data-i18n="reports.total">Total</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> <?php foreach ($monthly as $date => $total): ?>
<?php foreach ($monthly as $date => $total): ?> <tr>
<tr> <td><?= $date ?></td>
<td><?= $date ?></td> <td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td> </tr>
</tr> <?php endforeach; ?>
<?php endforeach; ?> </tbody>
</tbody> </table>
</table>
</div>
</div> </div>
<!-- Yearly Tab --> <!-- Yearly Tab -->
<div id="content-yearly" class="tab-content hidden"> <div id="content-yearly" class="tab-content hidden">
<div class="table-container"> <table class="table-glass" id="table-yearly">
<table class="table-glass"> <thead>
<thead> <tr>
<tr> <th data-i18n="reports.year">Year</th>
<th data-i18n="reports.year">Year</th> <th class="text-right" data-i18n="reports.total">Total</th>
<th class="text-right" data-i18n="reports.total">Total</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> <?php foreach ($yearly as $date => $total): ?>
<?php foreach ($yearly as $date => $total): ?> <tr>
<tr> <td><?= $date ?></td>
<td><?= $date ?></td> <td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td> </tr>
</tr> <?php endforeach; ?>
<?php endforeach; ?> </tbody>
</tbody> </table>
</table>
</div>
</div> </div>
<script src="/assets/js/components/datatable.js"></script>
<script> <script>
function switchTab(tabName) { document.addEventListener('DOMContentLoaded', () => {
// Hide all contents // Init Datatables
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden')); if (typeof SimpleDataTable !== 'undefined') {
// Show selected new SimpleDataTable('#table-daily', { itemsPerPage: 10, searchable: true });
document.getElementById('content-' + tabName).classList.remove('hidden'); new SimpleDataTable('#table-monthly', { itemsPerPage: 10, searchable: true });
new SimpleDataTable('#table-yearly', { itemsPerPage: 10, searchable: true });
// 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 function switchTab(tabName) {
const btn = document.getElementById('tab-' + tabName); // Hide all contents
btn.classList.remove('border-transparent', 'text-accents-5'); document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
btn.classList.add('border-primary', 'text-primary'); // 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> </script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?> <?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -9,197 +9,182 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<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> <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>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="location.reload()" class="btn btn-secondary"> <div class="dropdown dropdown-end relative" id="export-dropdown">
<button class="btn btn-secondary dropdown-toggle" onclick="document.getElementById('export-menu').classList.toggle('hidden')">
<i data-lucide="download" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.export">Export</span>
<i data-lucide="chevron-down" class="w-4 h-4 ml-1"></i>
</button>
<div id="export-menu" class="dropdown-menu hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-black border border-accents-2 z-50 p-1">
<button onclick="exportReport('csv')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
<i data-lucide="file-text" class="w-4 h-4 mr-2 text-green-600"></i> Export CSV
</button>
<button onclick="exportReport('xlsx')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
<i data-lucide="sheet" class="w-4 h-4 mr-2 text-green-600"></i> Export Excel
</button>
</div>
</div>
<button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span> <i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button> </button>
<button onclick="window.print()" class="btn btn-primary"> <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> <i data-lucide="printer" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.print_report">Print Report</span>
</button> </button>
</div> </div>
</div>
</div> </div>
<!-- Summary Cards --> <!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="card bg-accents-1 border-accents-2"> <!-- Stock / Potential -->
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_income">Total Income</div> <div class="card">
<div class="text-3xl font-bold text-green-500 mt-2"> <div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.generated_stock">Generated Stock</div>
<div class="text-3xl font-bold text-accents-6 mt-2">
<?= \App\Helpers\FormatHelper::formatCurrency($totalIncome, $currency) ?> <?= \App\Helpers\FormatHelper::formatCurrency($totalIncome, $currency) ?>
</div> </div>
<div class="text-xs text-accents-5 mt-1">
<?= number_format($totalVouchers) ?> vouchers
</div>
</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> <!-- Realized / Actual -->
<div class="text-3xl font-bold text-blue-500 mt-2"> <div class="card !bg-green-500/10 !border-green-500/20">
<?= number_format($totalVouchers, 0, ',', '.') ?> <div class="text-sm text-green-600 dark:text-green-400 uppercase font-bold tracking-wide" data-i18n="reports.realized_income">Realized Income</div>
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">
<?= \App\Helpers\FormatHelper::formatCurrency($totalRealizedIncome ?? 0, $currency) ?>
</div>
<div class="text-xs text-green-600/70 dark:text-green-400/70 mt-1">
<?= number_format($totalUsedVouchers ?? 0) ?> used
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-4"> <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 --> <!-- Detailed Table -->
<div class="table-container"> <table class="table-glass" id="report-table">
<table class="table-glass" id="report-table"> <thead>
<thead> <tr>
<th data-sort="date" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
<th data-i18n="reports.status">Status</th>
<th class="text-right" data-i18n="reports.qty">Qty (Stock)</th>
<th class="text-right text-green-500" data-i18n="reports.used">Used</th>
<th data-sort="total" class="text-right" data-i18n="reports.total_stock">Total Stock</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (empty($report)): ?>
<tr> <tr>
<th data-sort="date" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="reports.date_batch">Date / Batch (Comment)</th> <td colspan="5" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
<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> </tr>
</thead> <?php else: ?>
<tbody id="table-body"> <?php foreach ($report as $row): ?>
<?php if (empty($report)): ?> <tr class="table-row-item">
<tr> <td class="font-medium">
<td colspan="3" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td> <?= htmlspecialchars($row['date']) ?>
</td>
<td>
<?php if($row['status'] === 'New'): ?>
<span class="px-2 py-1 text-xs font-bold rounded-md bg-accents-2 text-accents-6">NEW</span>
<?php elseif($row['status'] === 'Selling'): ?>
<span class="px-2 py-1 text-xs font-bold rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/20">SELLING</span>
<?php elseif($row['status'] === 'Sold Out'): ?>
<span class="px-2 py-1 text-xs font-bold rounded-md bg-green-500/10 text-green-500 border border-green-500/20">SOLD OUT</span>
<?php endif; ?>
</td>
<td class="text-right font-mono text-accents-6">
<?= number_format($row['count']) ?>
</td>
<td class="text-right font-mono text-green-500 font-medium">
<?= number_format($row['realized_count']) ?>
<span class="text-xs opacity-70 block">
<?= \App\Helpers\FormatHelper::formatCurrency($row['realized_total'], $currency) ?>
</span>
</td>
<td class="text-right font-mono font-bold text-foreground">
<?= \App\Helpers\FormatHelper::formatCurrency($row['total'], $currency) ?>
</td>
</tr> </tr>
<?php else: ?> <?php endforeach; ?>
<?php foreach ($report as $row): ?> <?php endif; ?>
<tr class="table-row-item" </tbody>
data-date="<?= strtolower($row['date']) ?>" </table>
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> </div>
<script src="/assets/js/components/datatable.js"></script>
<!-- Local SheetJS Library -->
<script src="/assets/vendor/xlsx/xlsx.full.min.js"></script>
<script> <script>
class TableManager { document.addEventListener('DOMContentLoaded', () => {
constructor(rows, itemsPerPage = 15) { if (typeof SimpleDataTable !== 'undefined') {
this.allRows = Array.from(rows); new SimpleDataTable('#report-table', {
this.filteredRows = this.allRows; itemsPerPage: 15,
this.itemsPerPage = itemsPerPage; searchable: true,
this.currentPage = 1; pagination: true,
// Add Filter for Status Column (Index 1)
this.elements = { filters: [
body: document.getElementById('table-body'), { index: 1, label: 'Status: All' }
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() { async function exportReport(type) {
this.filteredRows = this.allRows.filter(row => { const url = '/<?= $session ?>/reports/selling/export/' + type;
const date = row.dataset.date || ''; const btn = document.querySelector('.dropdown-toggle');
const originalText = btn.innerHTML;
if (this.filters.search && !date.includes(this.filters.search)) return false;
// Show Loading State
return true; btn.innerHTML = `<i data-lucide="loader-2" class="w-4 h-4 mr-2 animate-spin"></i> Processing...`;
}); lucide.createIcons();
this.render();
}
render() { try {
const total = this.filteredRows.length; const response = await fetch(url);
const maxPage = Math.ceil(total / this.itemsPerPage) || 1; const data = await response.json();
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) { if (data.error) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`; alert('Export Failed: ' + data.error);
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`; return;
} }
if (typeof lucide !== 'undefined') lucide.createIcons(); const filename = `selling-report-<?= date('Y-m-d') ?>-${type}.` + (type === 'csv' ? 'csv' : 'xlsx');
if (type === 'csv') {
// Convert JSON to CSV
const header = Object.keys(data[0]);
const csv = [
header.join(','), // header row first
...data.map(row => header.map(fieldName => JSON.stringify(row[fieldName])).join(','))
].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.setAttribute('hidden', '');
a.setAttribute('href', url);
a.setAttribute('download', filename);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
else if (type === 'xlsx') {
// Use SheetJS for Real Excel
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Selling Report");
XLSX.writeFile(wb, filename);
}
} catch (error) {
console.error('Export Error:', error);
alert('Failed to export data. Check console for details.');
} finally {
// Restore Button
btn.innerHTML = originalText;
lucide.createIcons();
document.getElementById('export-menu').classList.add('hidden');
} }
} }
document.addEventListener('DOMContentLoaded', () => {
new TableManager(document.querySelectorAll('.table-row-item'), 15);
});
</script> </script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?> <?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,129 @@
<?php
// Plugins View
$title = "Plugins";
$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 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.plugins">Plugins</h1>
<p class="text-accents-5 mt-2" data-i18n="settings.plugins_desc">Manage and extend functionality with plugins.</p>
</div>
<button onclick="openUploadModal()" class="btn btn-primary">
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
<span data-i18n="settings.upload_plugin">Upload Plugin</span>
</button>
</div>
<!-- Content Area -->
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
<div class="card overflow-hidden p-0">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs text-accents-5 uppercase bg-accents-1/50 border-b border-accents-2 font-semibold tracking-wider">
<tr>
<th class="px-6 py-4 w-[250px]" data-i18n="common.name">Name</th>
<th class="px-6 py-4" data-i18n="common.description">Description</th>
<th class="px-6 py-4 w-[100px]" data-i18n="common.version">Version</th>
<th class="px-6 py-4 w-[150px]" data-i18n="common.author">Author</th>
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.status">Status</th>
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.actions">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-accents-2">
<?php if(empty($plugins)): ?>
<tr>
<td colspan="6" class="px-6 py-12 text-center text-accents-5">
<div class="flex flex-col items-center gap-3">
<div class="p-3 rounded-full bg-accents-1">
<i data-lucide="package-search" class="w-6 h-6 text-accents-4"></i>
</div>
<span class="font-medium" data-i18n="settings.no_plugins">No plugins installed</span>
<span class="text-xs" data-i18n="settings.no_plugins_desc">Upload a .zip file to get started.</span>
</div>
</td>
</tr>
<?php else: ?>
<?php foreach($plugins as $plugin): ?>
<tr class="group hover:bg-accents-1/30 transition-colors">
<td class="px-6 py-4 font-medium text-foreground">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded bg-primary/10 flex items-center justify-center text-primary">
<i data-lucide="plug" class="w-4 h-4"></i>
</div>
<div class="flex flex-col">
<span><?= htmlspecialchars($plugin['name']) ?></span>
<span class="text-[10px] text-accents-4 font-normal font-mono"><?= htmlspecialchars($plugin['id']) ?></span>
</div>
</div>
</td>
<td class="px-6 py-4 text-accents-6">
<?= htmlspecialchars($plugin['description']) ?>
</td>
<td class="px-6 py-4 text-accents-6 font-mono text-xs">
<?= htmlspecialchars($plugin['version']) ?>
</td>
<td class="px-6 py-4 text-accents-6">
<?= htmlspecialchars($plugin['author']) ?>
</td>
<td class="px-6 py-4 text-right">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
Active
</span>
</td>
<td class="px-6 py-4 text-right">
<form action="/settings/plugins/delete" method="POST" class="inline" onsubmit="event.preventDefault();
const title = window.i18n ? window.i18n.t('settings.delete_plugin') : 'Delete Plugin?';
const msg = window.i18n ? window.i18n.t('settings.delete_plugin_confirm', {name: '<?= htmlspecialchars($plugin['name']) ?>'}) : 'Delete this plugin?';
Mivo.confirm(title, msg, 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="plugin_id" value="<?= htmlspecialchars($plugin['id']) ?>">
<button type="submit" class="btn-icon-danger" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function openUploadModal() {
const title = window.i18n ? window.i18n.t('settings.upload_plugin') : 'Upload Plugin';
const html = `
<form id="upload-plugin-form" action="/settings/plugins/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
<div class="text-sm text-accents-5">
<p class="mb-4" data-i18n="settings.upload_plugin_desc">Select a plugin .zip file to install.</p>
<input type="file" name="plugin_file" accept=".zip" required class="form-control-file w-full">
</div>
</form>
`;
Mivo.modal.form(title, html, window.i18n ? window.i18n.t('common.install') : 'Install', () => {
const form = document.getElementById('upload-plugin-form');
if (form.reportValidity()) {
form.submit();
return true;
}
return false;
});
}
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -12,6 +12,13 @@ $initialContent = $template['content'] ?? '<div style="border: 1px solid #000; p
require_once ROOT . '/app/Views/layouts/header_main.php'; require_once ROOT . '/app/Views/layouts/header_main.php';
?> ?>
<style>
/* Make CodeMirror fill the entire container height */
#editorContainer .cm-editor {
height: 100%;
}
</style>
<div class="flex flex-col lg:h-[calc(100vh-8rem)] gap-6"> <div class="flex flex-col lg:h-[calc(100vh-8rem)] gap-6">
<!-- Header --> <!-- Header -->
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 flex-shrink-0"> <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 flex-shrink-0">
@@ -64,7 +71,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</div> </div>
</div> </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 id="editorContainer" class="flex-1 w-full font-mono text-sm h-full min-h-0 border-none outline-none overflow-hidden bg-background"></div>
<!-- Hidden textarea for form submission -->
<textarea id="codeEditor" name="content" form="templateForm" class="hidden"><?= htmlspecialchars($initialContent) ?></textarea>
</div> </div>
<!-- Right: Preview --> <!-- Right: Preview -->
@@ -84,6 +93,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</div> </div>
<script src="/assets/js/qrious.min.js"></script> <script src="/assets/js/qrious.min.js"></script>
<script src="/assets/js/vendor/editor.bundle.js"></script>
</div> </div>
<!-- Documentation Modal --> <!-- Documentation Modal -->
@@ -103,6 +113,18 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<div class="p-6 overflow-y-auto custom-scrollbar"> <div class="p-6 overflow-y-auto custom-scrollbar">
<div class="prose dark:prose-invert max-w-none"> <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> <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>
<!-- NEW: Editor Shortcuts & Emmet -->
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2" data-i18n="settings.editor_shortcuts">Editor Shortcuts & Emmet</h3>
<div class="p-4 rounded bg-accents-1 border border-accents-2 mb-6">
<p class="text-sm text-accents-6 mb-4" data-i18n="settings.emmet_desc">Use Emmet abbreviations for fast coding. Look for the dotted underline, then press Tab.</p>
<ul class="text-sm space-y-4 list-disc list-inside text-accents-6">
<li data-i18n="settings.tip_emmet_html"><strong>HTML Boilerplate</strong>: Type <code>!</code> then <code>Tab</code>.</li>
<li data-i18n="settings.tip_emmet_tag"><strong>Auto-Tag</strong>: Type <code>.container</code> then <code>Tab</code> for <code>&lt;div class="container"&gt;</code>.</li>
<li data-i18n="settings.tip_color_picker"><strong>Color Picker</strong>: Click the color box next to hex codes (e.g., #ff0000) to open the picker.</li>
<li data-i18n="settings.tip_syntax_error"><strong>Syntax Error</strong>: Red squiggles (and dots in the gutter) show structure errors like mismatched tags.</li>
</ul>
</div>
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2">Basic Variables</h3> <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="grid grid-cols-1 gap-2 mb-6">
@@ -201,148 +223,43 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
} }
} }
// --- Editor Logic --- // --- Editor Logic (CodeMirror 6) ---
const editor = document.getElementById('codeEditor'); const textarea = document.getElementById('codeEditor');
const container = document.getElementById('editorContainer');
const preview = document.getElementById('previewContainer'); const preview = document.getElementById('previewContainer');
const isDark = document.documentElement.classList.contains('dark');
// History Stack for Undo/Redo let cmView = null;
let historyStack = [];
let redoStack = [];
let isTyping = false;
let typingTimer = null;
// Initial State
historyStack.push({ value: editor.value, selectionStart: 0, selectionEnd: 0 });
function saveState() { function initEditor() {
// Limit stack size if (typeof MivoEditor === 'undefined') {
if (historyStack.length > 50) historyStack.shift(); console.error('CodeMirror bundle not loaded yet.');
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; return;
} }
// Tab: Insert/Remove Indent cmView = MivoEditor.init({
if (e.key === 'Tab') { parent: container,
e.preventDefault(); initialValue: textarea.value,
const start = this.selectionStart; dark: isDark,
const end = this.selectionEnd; onChange: (val) => {
const val = this.value; textarea.value = val;
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(); updatePreview();
} }
} });
// Enter: Auto-indent checking previous line // Set focus
if (e.key === 'Enter') { cmView.focus();
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) { function insertVar(text) {
saveState(); // Save state before insertion if (!cmView) return;
const start = editor.selectionStart; const selection = cmView.state.selection.main;
const end = editor.selectionEnd; cmView.dispatch({
const val = editor.value; changes: { from: selection.from, to: selection.to, insert: text },
editor.value = val.substring(0, start) + text + val.substring(end); selection: { anchor: selection.from + text.length }
editor.selectionStart = editor.selectionEnd = start + text.length; });
editor.focus(); cmView.focus();
saveState(); // Save state after insertion
updatePreview();
} }
// Live Preview Logic // Live Preview Logic
@@ -359,16 +276,16 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
'{{timelimit}}': ' 3 Hours', '{{timelimit}}': ' 3 Hours',
'{{datalimit}}': '500 MB', '{{datalimit}}': '500 MB',
'{{profile}}': 'General', '{{profile}}': 'General',
'{{comment}}': 'mikhmon', '{{comment}}': 'mivo',
'{{hotspotname}}': 'Mikhmon Hotspot', '{{hotspotname}}': 'Mivo Hotspot',
'{{num}}': '1', '{{num}}': '1',
'{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder '{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder
'{{dns_name}}': 'hotspot.mikhmon', '{{dns_name}}': 'hotspot.mivo',
'{{login_url}}': 'http://hotspot.mikhmon/login', '{{login_url}}': 'http://hotspot.mivo/login',
}; };
function updatePreview() { function updatePreview() {
let content = editor.value; let content = textarea.value;
// 1. Handle {{logo id=...}} // 1. Handle {{logo id=...}}
content = content.replace(/\{\{logo\s+id=['"]?([^'"\s]+)['"]?\}\}/gi, (match, id) => { content = content.replace(/\{\{logo\s+id=['"]?([^'"\s]+)['"]?\}\}/gi, (match, id) => {
@@ -479,10 +396,39 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
preview.innerHTML = content; preview.innerHTML = content;
} }
editor.addEventListener('input', updatePreview); // Handled by debouncer above too, but OK.
// Init // Init
updatePreview(); document.addEventListener('DOMContentLoaded', () => {
initEditor();
updatePreview();
});
// Theme Switch Recognition
window.addEventListener('languageChanged', () => {
// Not language, but theme toggle button often triggers layout shifts.
// We might need a MutationObserver if we want to live-toggle CM theme.
// For now, reload or manual re-init on theme toggle could work.
});
// Watch for theme changes globally
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class' && mutation.target === document.documentElement) {
// Theme changed
// CodeMirror 6 themes are extensions, changing them requires re-configuring the state.
// For simplicity, let's just re-init everything if theme changes.
const newIsDark = document.documentElement.classList.contains('dark');
if (cmView) {
const content = cmView.state.doc.toString();
container.innerHTML = '';
cmView = null;
textarea.value = content;
initEditor();
}
}
});
});
observer.observe(document.documentElement, { attributes: true });
</script> </script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?> <?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -1,12 +1,12 @@
{ {
"name": "dyzulk/mivo", "name": "mivodev/mivo",
"description": "MIVO - Modern Mikrotik Voucher Management System", "description": "MIVO - Modern Mikrotik Voucher Management System",
"type": "project", "type": "project",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
{ {
"name": "DyzulkDev", "name": "MivoDev",
"email": "dev@dyzulk.com" "email": "mivo@dev.dyzulk.com"
} }
], ],
"require": { "require": {

View File

@@ -1,53 +0,0 @@
$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

View File

@@ -2,7 +2,7 @@ version: '3.8'
services: services:
mivo: mivo:
image: dyzulk/mivo:${VERSION:-latest} image: ghcr.io/mivodev/mivo:${VERSION:-latest}
container_name: ${CONTAINER_NAME:-mivo} container_name: ${CONTAINER_NAME:-mivo}
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

@@ -1,61 +0,0 @@
{
"hash": "351ebb31",
"configHash": "9f61585a",
"lockfileHash": "a8ce03f4",
"browserHash": "3a62ec11",
"optimized": {
"vue": {
"src": "../../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "5b1d304c",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../../node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "b482c46a",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../../node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "a5c5890d",
"needsInterop": false
},
"vitepress > @vueuse/integrations/useFocusTrap": {
"src": "../../../../node_modules/@vueuse/integrations/useFocusTrap.mjs",
"file": "vitepress___@vueuse_integrations_useFocusTrap.js",
"fileHash": "95f44bf0",
"needsInterop": false
},
"vitepress > mark.js/src/vanilla.js": {
"src": "../../../../node_modules/mark.js/src/vanilla.js",
"file": "vitepress___mark__js_src_vanilla__js.js",
"fileHash": "9dc093d4",
"needsInterop": false
},
"vitepress > minisearch": {
"src": "../../../../node_modules/minisearch/dist/es/index.js",
"file": "vitepress___minisearch.js",
"fileHash": "55d2ce5f",
"needsInterop": false
},
"lucide-vue-next": {
"src": "../../../../node_modules/lucide-vue-next/dist/esm/lucide-vue-next.js",
"file": "lucide-vue-next.js",
"fileHash": "d70b9ca5",
"needsInterop": false
}
},
"chunks": {
"chunk-2CLQ7TTZ": {
"file": "chunk-2CLQ7TTZ.js"
},
"chunk-LE5NDSFD": {
"file": "chunk-LE5NDSFD.js"
},
"chunk-PZ5AY32C": {
"file": "chunk-PZ5AY32C.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +0,0 @@
var __defProp = Object.defineProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
export {
__export
};
//# sourceMappingURL=chunk-PZ5AY32C.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,584 +0,0 @@
import {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
} from "./chunk-2CLQ7TTZ.js";
import "./chunk-LE5NDSFD.js";
import "./chunk-PZ5AY32C.js";
export {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
computedAsync as asyncComputed,
refAutoReset as autoResetRef,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
computedWithControl as controlledComputed,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
reactify as createReactiveFn,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
refDebounced as debouncedRef,
watchDebounced as debouncedWatch,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
computedEager as eagerComputed,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
watchIgnorable as ignorableWatch,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
watchPausable as pausableWatch,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
refThrottled as throttledRef,
watchThrottled as throttledWatch,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
refDebounced as useDebounce,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
refThrottled as useThrottle,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
};
//# sourceMappingURL=vitepress___@vueuse_core.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,348 +0,0 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-LE5NDSFD.js";
import "./chunk-PZ5AY32C.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -1,81 +0,0 @@
import { defineConfig } from 'vitepress'
import { sidebarEn, sidebarId } from './config/sidebars'
import { navEn, navId } from './config/nav'
export default defineConfig({
title: "MIVO",
description: "Modern Mikrotik Voucher Management System",
lang: 'en-US',
cleanUrls: true,
lastUpdated: true,
sitemap: {
hostname: 'https://docs.mivo.dyzulk.com'
},
head: [
['link', { rel: 'icon', href: '/logo-m.svg' }],
['meta', { name: 'theme-color', content: '#0ea5e9' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: 'MIVO' }],
['meta', { property: 'og:image', content: 'https://docs.mivo.dyzulk.com/og-image.png' }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
['meta', { name: 'twitter:image', content: 'https://docs.mivo.dyzulk.com/og-image.png' }],
['meta', { name: 'twitter:site', content: '@dyzulkdev' }]
],
transformHead: ({ pageData }) => {
const title = pageData.title ? `${pageData.title} | MIVO` : 'MIVO'
const description = pageData.description || "Modern Mikrotik Voucher Management System"
const url = `https://docs.mivo.dyzulk.com/${pageData.relativePath.replace(/((^|\/)index)?\.md$/, '$2')}`
return [
['meta', { property: 'og:title', content: title }],
['meta', { property: 'og:description', content: description }],
['meta', { property: 'og:url', content: url }],
['meta', { name: 'twitter:title', content: title }],
['meta', { name: 'twitter:description', content: description }],
]
},
// Shared theme config
themeConfig: {
logo: {
light: '/logo-m.svg',
dark: '/logo-m-dark.svg'
},
siteTitle: 'MIVO',
socialLinks: [
{ icon: 'github', link: 'https://github.com/dyzulk/mivo' }
],
footer: {
message: 'Released under the MIT License.',
copyright: `Copyright © 2026${new Date().getFullYear() > 2026 ? ' - ' + new Date().getFullYear() : ''} DyzulkDev`
},
search: {
provider: 'local'
}
},
locales: {
root: {
label: 'English',
lang: 'en',
themeConfig: {
nav: navEn,
sidebar: sidebarEn
}
},
id: {
label: 'Indonesia',
lang: 'id',
themeConfig: {
nav: navId,
sidebar: sidebarId
}
}
}
})

View File

@@ -1,31 +0,0 @@
import { DefaultTheme } from 'vitepress'
export const navEn: DefaultTheme.NavItem[] = [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/installation' },
{ text: 'Manual', link: '/manual/' },
{
text: 'Community',
items: [
{ text: 'Changelog', link: 'https://github.com/dyzulk/mivo/releases' },
{ text: 'Contributing', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
{ text: 'Forum (Issues)', link: 'https://github.com/dyzulk/mivo/issues' },
{ text: 'Star on GitHub', link: 'https://github.com/dyzulk/mivo/stargazers' }
]
}
]
export const navId: DefaultTheme.NavItem[] = [
{ text: 'Beranda', link: '/id/' },
{ text: 'Panduan', link: '/id/guide/installation' },
{ text: 'Buku Manual', link: '/id/manual/' },
{
text: 'Komunitas',
items: [
{ text: 'Catatan Rilis', link: 'https://github.com/dyzulk/mivo/releases' },
{ text: 'Kontribusi', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
{ text: 'Forum (Issues)', link: 'https://github.com/dyzulk/mivo/issues' },
{ text: 'Beri Bintang', link: 'https://github.com/dyzulk/mivo/stargazers' }
]
}
]

View File

@@ -1,147 +0,0 @@
import { DefaultTheme } from 'vitepress'
// English Sidebars
export const sidebarEn: DefaultTheme.Sidebar = {
// Sidebar for /guide/ path
'/guide/': [
{
text: 'Getting Started',
collapsed: false,
items: [
{ text: 'Introduction', link: '/guide/' },
{ text: 'Requirements', link: '/guide/installation#requirements' }
]
},
{
text: 'Installation',
collapsed: false,
items: [
{ text: 'Docker', link: '/guide/docker' },
{ text: 'aaPanel (Docker)', link: '/guide/docker-aapanel' },
{ text: 'Web Server', link: '/guide/installation#web-servers' },
{ text: 'Shared Hosting', link: '/guide/installation#shared-hosting' },
{ text: 'VPS & Cloud', link: '/guide/installation#vps-cloud' },
{ text: 'Mobile & STB', link: '/guide/installation#mobile-stb' }
]
},
{
text: 'Configuration',
items: [
{ text: 'Post-Installation', link: '/guide/installation#post-installation' }
]
},
{
text: 'Support',
items: [
{ text: 'Contribution', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
{ text: 'Donate', link: 'https://sociabuzz.com/dyzulkdev/tribe' }
]
}
],
// Sidebar for /manual/ path
'/manual/': [
{
text: 'User Manual',
items: [
{ text: 'Overview', link: '/manual/' }
]
},
{
text: 'Global Settings',
items: [
{ text: 'Introduction', link: '/manual/settings/' },
{ text: 'Routers', link: '/manual/settings/routers' },
{ text: 'Templates', link: '/manual/settings/templates' },
{ text: 'Logos', link: '/manual/settings/logos' },
{ text: 'API & CORS', link: '/manual/settings/api-cors' },
{ text: 'System', link: '/manual/settings/system' }
]
},
{
text: 'Router Operations',
items: [
{ text: 'Introduction', link: '/manual/router/' },
{ text: 'Dashboard', link: '/manual/router/dashboard' },
{ text: 'Quick Print', link: '/manual/router/quick-print' },
{ text: 'Hotspot Management', link: '/manual/router/hotspot' },
{ text: 'Reports & Logs', link: '/manual/router/reports' },
{ text: 'Network & System', link: '/manual/router/tools' }
]
}
]
}
// Indonesian Sidebars
export const sidebarId: DefaultTheme.Sidebar = {
// Sidebar for /id/guide/ path
'/id/guide/': [
{
text: 'Pengenalan',
collapsed: false,
items: [
{ text: 'Apa itu MIVO?', link: '/id/guide/' },
{ text: 'Persyaratan', link: '/id/guide/installation#persyaratan' }
]
},
{
text: 'Instalasi',
collapsed: false,
items: [
{ text: 'Docker', link: '/id/guide/docker' },
{ text: 'aaPanel (Docker)', link: '/id/guide/docker-aapanel' },
{ text: 'Web Server', link: '/id/guide/installation#web-server' },
{ text: 'Shared Hosting', link: '/id/guide/installation#shared-hosting' },
{ text: 'VPS & Cloud', link: '/id/guide/installation#vps-cloud' },
{ text: 'Mobile & STB', link: '/id/guide/installation#mobile-stb' }
]
},
{
text: 'Konfigurasi',
items: [
{ text: 'Pasca-Instalasi', link: '/id/guide/installation#pasca-instalasi' }
]
},
{
text: 'Dukungan',
items: [
{ text: 'Kontribusi', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
{ text: 'Donasi', link: 'https://sociabuzz.com/dyzulkdev/tribe' }
]
}
],
// Sidebar for /id/manual/ path
'/id/manual/': [
{
text: 'Buku Manual',
items: [
{ text: 'Ringkasan', link: '/id/manual/' }
]
},
{
text: 'Pengaturan Global',
items: [
{ text: 'Pendahuluan', link: '/id/manual/settings/' },
{ text: 'Router', link: '/id/manual/settings/routers' },
{ text: 'Template', link: '/id/manual/settings/templates' },
{ text: 'Logo', link: '/id/manual/settings/logos' },
{ text: 'API & CORS', link: '/id/manual/settings/api-cors' },
{ text: 'Sistem', link: '/id/manual/settings/system' }
]
},
{
text: 'Operasional Router',
items: [
{ text: 'Pendahuluan', link: '/id/manual/router/' },
{ text: 'Dashboard', link: '/id/manual/router/dashboard' },
{ text: 'Cetak Cepat', link: '/id/manual/router/quick-print' },
{ text: 'Manajemen Hotspot', link: '/id/manual/router/hotspot' },
{ text: 'Laporan & Log', link: '/id/manual/router/reports' },
{ text: 'Jaringan & Sistem', link: '/id/manual/router/tools' }
]
}
]
}

View File

@@ -1,82 +0,0 @@
<script setup>
import DefaultTheme from 'vitepress/theme'
const { Layout } = DefaultTheme
</script>
<template>
<Layout>
<template #layout-bottom>
<div class="mivo-bg">
<!-- Subtle Grid Pattern -->
<div class="mivo-grid"></div>
<!-- Glowing Orbs -->
<div class="mivo-orb orb-1"></div>
<div class="mivo-orb orb-2"></div>
</div>
</template>
</Layout>
</template>
<style scoped>
.mivo-bg {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
overflow: hidden;
}
.mivo-grid {
position: absolute;
inset: 0;
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMCwwLDAsMC4zKSIvPjwvc3ZnPg==');
mask-image: linear-gradient(to bottom, white, transparent);
-webkit-mask-image: linear-gradient(to bottom, white, transparent);
}
:root.dark .mivo-grid {
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wNSkiLz48L3N2Zz4=');
}
.mivo-orb {
position: absolute;
border-radius: 9999px;
filter: blur(100px);
opacity: 0.2;
animation: pulse 8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
:root.dark .mivo-orb {
opacity: 0.05;
}
.orb-1 {
top: -20%;
left: -10%;
width: 70vw;
height: 70vw;
background-color: #3b82f6; /* blue-500 */
animation-duration: 4s;
}
.orb-2 {
top: 30%;
right: -15%;
width: 60vw;
height: 60vw;
background-color: #a855f7; /* purple-500 */
animation-duration: 6s;
animation-delay: 1s;
}
@keyframes pulse {
0%, 100% { opacity: 0.2; }
50% { opacity: 0.15; }
}
:root.dark @keyframes pulse {
0%, 100% { opacity: 0.05; }
50% { opacity: 0.03; }
}
</style>

View File

@@ -1,60 +0,0 @@
<template>
<component
:is="iconComponent"
v-if="iconComponent"
:size="size || 20"
:stroke-width="strokeWidth || 2"
class="lucide-icon"
:style="{ color: resolvedColor }"
v-bind="$attrs"
/>
</template>
<script setup>
import { computed } from 'vue'
import * as icons from 'lucide-vue-next'
const props = defineProps({
name: {
type: String,
required: true
},
size: [Number, String],
strokeWidth: [Number, String],
color: {
type: String,
default: 'base'
}
})
const semanticColors = {
base: 'var(--mivo-icon-base)',
muted: 'var(--mivo-icon-muted)',
primary: 'var(--mivo-icon-primary)',
info: 'var(--mivo-icon-info)',
success: 'var(--mivo-icon-success)',
warning: 'var(--mivo-icon-warning)',
danger: 'var(--mivo-icon-danger)'
}
const resolvedColor = computed(() => {
return semanticColors[props.color] || props.color
})
const iconComponent = computed(() => {
// Handle both PascalCase (Search) and kebab-case (search-icon)
const pascalName = props.name
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
return icons[pascalName] || icons[props.name]
})
</script>
<style scoped>
.lucide-icon {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -1,13 +0,0 @@
import DefaultTheme from 'vitepress/theme'
import Layout from './Layout.vue'
import Icon from './components/Icon.vue'
import 'flag-icons/css/flag-icons.min.css'
import './style.css'
export default {
extends: DefaultTheme,
Layout: Layout,
enhanceApp({ app }) {
app.component('Icon', Icon)
}
}

View File

@@ -1,429 +0,0 @@
/* Fonts Setup */
@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;
}
:root {
/* Fonts */
--vp-font-family-base: "Geist", sans-serif;
--vp-font-family-mono: "Geist Mono", monospace;
/* Colors Override to match MIVO */
--vp-c-bg: #ffffff;
--vp-c-bg-alt: #fafafa;
--vp-c-bg-elv: #ffffff;
--vp-c-text-1: #000000;
--vp-c-text-2: #666666;
--vp-c-brand-1: #000000;
--vp-c-brand-2: #333333;
--vp-c-brand-3: #666666;
/* Icon Colors - Light */
--mivo-icon-base: var(--vp-c-text-1);
--mivo-icon-muted: var(--vp-c-text-2);
--mivo-icon-primary: var(--vp-c-brand-1);
--mivo-icon-info: #3b82f6;
--mivo-icon-success: #22c55e;
--mivo-icon-warning: #eab308;
--mivo-icon-danger: #ef4444;
}
.dark {
--vp-c-bg: #000000;
--vp-c-bg-alt: #111111;
--vp-c-bg-elv: #111111;
--vp-c-text-1: #ffffff;
--vp-c-text-2: #888888;
--vp-c-brand-1: #ffffff;
--vp-c-brand-2: #eaeaea;
--vp-c-brand-3: #999999;
/* Icon Colors - Dark */
--mivo-icon-base: var(--vp-c-text-1);
--mivo-icon-muted: var(--vp-c-text-2);
--mivo-icon-primary: var(--vp-c-brand-1);
--mivo-icon-info: #60a5fa;
--mivo-icon-success: #4ade80;
--mivo-icon-warning: #facc15;
--mivo-icon-danger: #f87171;
}
/* Glassmorphism Overrides */
/* Navbar Glass */
.VPNav {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.dark .VPNav {
background-color: rgba(0, 0, 0, 0.6) !important;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
/* Sidebar Glass */
.VPSidebar {
background-color: rgba(255, 255, 255, 0.3) !important;
backdrop-filter: blur(12px);
border-right: 1px solid rgba(0,0,0,0.05);
}
.dark .VPSidebar {
background-color: rgba(0, 0, 0, 0.3) !important;
border-right: 1px solid rgba(255,255,255,0.1);
}
/* Content Container - Transparent to show particles */
.VPContent {
background: transparent !important;
}
/* Footer Glass */
.VPFooter {
background-color: transparent !important;
border-top: 1px solid rgba(0,0,0,0.05);
}
.dark .VPFooter {
border-top: 1px solid rgba(255,255,255,0.1);
}
/* Local Search Box Glass */
.VPLocalSearchBox {
background-color: rgba(255, 255, 255, 0.8) !important;
backdrop-filter: blur(12px);
}
.dark .VPLocalSearchBox {
background-color: rgba(0, 0, 0, 0.8) !important;
}
/* Glassmorphism Cards (Feature Cards) */
.VPFeature {
background-color: rgba(255, 255, 255, 0.25) !important;
backdrop-filter: blur(40px);
border: 2px solid rgba(255, 255, 255, 0.2) !important;
border-radius: 1rem !important; /* rounded-2xl */
transition: all 0.3s ease;
}
.dark .VPFeature {
background-color: rgba(0, 0, 0, 0.4) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.VPFeature:hover {
border-color: rgba(255, 255, 255, 0.4) !important;
background-color: rgba(255, 255, 255, 0.4) !important;
transform: translateY(-4px);
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.1);
}
.dark .VPFeature:hover {
border-color: rgba(255, 255, 255, 0.2) !important;
background-color: rgba(0, 0, 0, 0.6) !important;
}
/* Fix text colors inside cards if needed */
/* Global Button Styling (MIVO Style) */
.VPButton {
border-radius: 0.375rem !important; /* rounded-md */
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
font-weight: 500 !important;
text-transform: none !important;
letter-spacing: normal !important;
}
.VPButton:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.VPButton:active {
transform: scale(0.95);
}
/* Primary Button (Brand) */
.VPButton.brand {
background-color: var(--vp-c-brand-1) !important;
border-color: var(--vp-c-brand-1) !important;
color: var(--vp-c-bg) !important;
}
.dark .VPButton.brand {
color: #000 !important; /* Ensure black text on white button in dark mode */
}
/* Secondary Button (Alt) */
.VPButton.alt {
background-color: transparent !important;
border: 1px solid var(--vp-c-brand-2) !important;
color: var(--vp-c-text-1) !important;
}
/* Mobile Navigation (Glass Overlay) */
.VPNavScreen {
background-color: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
position: fixed !important;
top: var(--vp-nav-height, 64px) !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: calc(100vh - var(--vp-nav-height, 64px)) !important; /* Force Height */
z-index: 90 !important;
overflow-y: auto !important;
opacity: 1 !important; /* Always opacity 1 if visible */
pointer-events: auto !important;
/* Remove transition that might delay visibility */
transition: background-color 0.25s !important;
}
/* Rely on VitePress/Vue to toggle 'display' property */
/* We just ensure that IF it is displayed, it looks right */
.dark .VPNavScreen {
background-color: rgba(0, 0, 0, 0.95) !important;
}
/* Mobile Sidebar (Slide-out) */
@media (max-width: 960px) {
.VPSidebar {
background-color: rgba(255, 255, 255, 0.9) !important; /* Less transparent on mobile for readability */
backdrop-filter: blur(20px);
border-right: none !important;
box-shadow: 10px 0 30px rgba(0,0,0,0.1);
}
.dark .VPSidebar {
background-color: rgba(20, 20, 20, 0.9) !important;
}
}
/* Fix Hamburger Menu Icon Color & Interaction */
.VPNavBarHamburger {
cursor: pointer !important;
pointer-events: auto !important;
z-index: 100 !important;
}
.VPNavBarHamburger .container .top,
.VPNavBarHamburger .container .middle,
.VPNavBarHamburger .container .bottom {
background-color: var(--vp-c-text-1) !important;
}
/* Ensure Logo Text is Visible on Mobile */
/* High Contrast Sidebar & TOC */
/* High Contrast Sidebar & TOC */
.VPSidebarItem .text {
color: var(--vp-c-text-2) !important;
opacity: 1 !important;
font-weight: 400 !important;
}
/* Level 0 Parent Headers (Collapsible) */
.VPSidebarItem.level-0 > .item > .text,
.VPSidebarItem.level-0 > .item > .VPLink > .text {
color: var(--vp-c-text-1) !important;
font-weight: 700 !important; /* Bold for headers */
}
/* Level 1+ Sub-items explicitly regular */
.VPSidebarItem.level-1 .text,
.VPSidebarItem.level-2 .text {
font-weight: 400 !important;
}
/* Sidebar Lines (GitBook Style) */
.VPSidebarItem.level-0 > .items {
border-left: 1px solid rgba(0, 0, 0, 0.05); /* Light vertical line */
margin-left: 1.15rem;
padding-left: 0.5rem;
}
.dark .VPSidebarItem.level-0 > .items {
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
/* Active Sidebar Item Differentiation */
.VPSidebarItem.is-active > .item {
border-left: 2px solid var(--vp-c-brand-1); /* Bold indicator line */
margin-left: calc(-0.5rem - 1px); /* Overlap the group line */
padding-left: calc(0.5rem - 1px);
}
.VPSidebarItem.is-active > .item > .text,
.VPSidebarItem.is-active > .item > .VPLink > .text {
color: var(--vp-c-brand-1) !important;
font-weight: 400 !important; /* Keep active sub-item thinner than collapsible header */
background-color: transparent !important; /* Clean style */
}
/* Navbar Active Underline */
.VPNavBarMenuLink.active {
color: var(--vp-c-brand-1) !important;
position: relative;
}
.VPNavBarMenuLink.active::after {
content: "";
position: absolute;
bottom: -4px;
left: 0;
width: 100%;
height: 2px;
background-color: var(--vp-c-brand-1);
}
.VPDocOutlineItem .text {
color: var(--vp-c-text-2) !important;
font-size: 0.85rem;
font-weight: 400 !important;
}
.VPDocOutlineItem.active .text {
color: var(--vp-c-brand-1) !important;
font-weight: 600 !important;
}
/* Dark Mode Specific Contrast Boost */
.dark .VPSidebarItem .text,
.dark .VPDocOutlineItem .text {
color: #b0b0b0 !important; /* Brighter than default gray */
}
.dark .VPSidebarItem.is-active > .item > .text,
.dark .VPSidebarItem.is-active > .item > .VPLink > .text,
.dark .VPDocOutlineItem.active .text,
.dark .VPSidebarItem.level-0 > .item > .text,
.dark .VPSidebarItem.level-0 > .item > .VPLink > .text {
color: #ffffff !important;
/* Font weights are already inherited or set above */
}
/* Fix Code Block Background to be Glassy too */
.vp-code-group .tabs {
background-color: rgba(255,255,255,0.5) !important;
}
.dark .vp-code-group .tabs {
background-color: rgba(0,0,0,0.5) !important;
}
/* Markdown Divider Contrast */
.vp-doc hr {
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.15); /* Stronger in light mode */
margin: 3rem 0;
}
.dark .vp-doc hr {
border-top: 1px solid rgba(255, 255, 255, 0.2); /* Stronger in dark mode */
}
/* Pagination Cards */
.pager-link {
background-color: rgba(255, 255, 255, 0.4) !important;
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.05) !important;
border-radius: 0.75rem !important; /* rounded-xl */
padding: 1.5rem !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
text-decoration: none !important;
display: flex !important;
flex-direction: column !important;
}
.dark .pager-link {
background-color: rgba(255, 255, 255, 0.03) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.pager-link:hover {
border-color: var(--vp-c-brand-1) !important;
transform: translateY(-4px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background-color: rgba(255, 255, 255, 0.6) !important;
}
.dark .pager-link:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.pager-link .desc {
color: var(--vp-c-text-2) !important;
font-size: 0.8rem !important;
font-weight: 500 !important;
margin-bottom: 4px !important;
display: block !important;
}
.pager-link .title {
color: var(--vp-c-brand-1) !important;
font-size: 1.1rem !important;
font-weight: 700 !important;
}
.pager-link.next {
text-align: right !important;
align-items: flex-end !important;
}
/* Language Switcher Flags Integration */
.VPNavBarTranslations .items .title::before,
.VPNavBarTranslations .items .VPMenuLink .VPLink.link span::before {
content: "";
display: inline-block;
width: 1.33333333em;
height: 1em;
margin-right: 0.6rem;
vertical-align: middle;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* --- English Flag (US) --- */
/* 1. Target the link to English (Any link NOT starting with /id/) */
.VPNavBarTranslations a:not([href^="/id/"])::before,
.VPNavBarTranslations a:not([href^="/id/"]) span::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23bd3d44' d='M0 0h640v480H0z'/%3E%3Cpath stroke='%23fff' stroke-width='37' d='M0 74h640M0 148h640M0 222h640M0 296h640M0 370h640M0 444h640'/%3E%3Cpath fill='%23192f5d' d='M0 0h364v258.5H0z'/%3E%3Cg fill='%23fff'%3E%3Cg id='a'%3E%3Cg id='b'%3E%3Cpath id='c' d='M31 23l5 15.5-13.1-9.5H44l-13.1 9.5'/%3E%3Cuse href='%23c' x='62'/%3E%3Cuse href='%23c' x='124'/%3E%3Cuse href='%23c' x='186'/%3E%3Cuse href='%23c' x='248'/%3E%3C/g%3E%3Cuse href='%23b' x='31' y='21'/%3E%3C/g%3E%3Cuse href='%23a' y='42'/%3E%3Cuse href='%23a' y='84'/%3E%3Cuse href='%23a' y='126'/%3E%3Cuse href='%23a' y='168'/%3E%3C/g%3E%3C/svg%3E");
}
/* 2. Target the active English title (when on /) */
html[lang="en"] .VPNavBarTranslations .items .title::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23bd3d44' d='M0 0h640v480H0z'/%3E%3Cpath stroke='%23fff' stroke-width='37' d='M0 74h640M0 148h640M0 222h640M0 296h640M0 370h640M0 444h640'/%3E%3Cpath fill='%23192f5d' d='M0 0h364v258.5H0z'/%3E%3Cg fill='%23fff'%3E%3Cg id='a'%3E%3Cg id='b'%3E%3Cpath id='c' d='M31 23l5 15.5-13.1-9.5H44l-13.1 9.5'/%3E%3Cuse href='%23c' x='62'/%3E%3Cuse href='%23c' x='124'/%3E%3Cuse href='%23c' x='186'/%3E%3Cuse href='%23c' x='248'/%3E%3C/g%3E%3Cuse href='%23b' x='31' y='21'/%3E%3C/g%3E%3Cuse href='%23a' y='42'/%3E%3Cuse href='%23a' y='84'/%3E%3Cuse href='%23a' y='126'/%3E%3Cuse href='%23a' y='168'/%3E%3C/g%3E%3C/svg%3E");
}
/* --- Indonesia Flag (ID) --- */
/* 1. Target the link to Indonesia (Any link starting with /id/) */
.VPNavBarTranslations a[href^="/id/"]::before,
.VPNavBarTranslations a[href^="/id/"] span::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23e12127' d='M0 0h640v240H0z'/%3E%3Cpath fill='%23fff' d='M0 240h640v240H0z'/%3E%3C/svg%3E");
}
/* 2. Target the active Indonesia title (when on /id/) */
html[lang="id"] .VPNavBarTranslations .items .title::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23e12127' d='M0 0h640v240H0z'/%3E%3Cpath fill='%23fff' d='M0 240h640v240H0z'/%3E%3C/svg%3E");
}

View File

@@ -21,9 +21,9 @@ The easiest way to run MIVO.
2. **Manual Pull (Alternative)** 2. **Manual Pull (Alternative)**
If you prefer to pull the image manually: If you prefer to pull the image manually:
```bash ```bash
docker pull dyzulk/mivo:latest # Stable docker pull ghcr.io/mivodev/mivo:latest # Stable
docker pull dyzulk/mivo:v1.0.0 # Specific Version docker pull ghcr.io/mivodev/mivo:v1.0.0 # Specific Version
docker pull dyzulk/mivo:edge # Bleeding Edge docker pull ghcr.io/mivodev/mivo:edge # Bleeding Edge
``` ```
*Note: The database is persisted in `app/Database` via volumes.* *Note: The database is persisted in `app/Database` via volumes.*

1
docs/README.md Normal file
View File

@@ -0,0 +1 @@
Documentation has moved to https://mivodev.github.io

View File

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

View File

@@ -1,78 +0,0 @@
---
title: Docker Guide
---
# Docker Guide
This Docker image is built on **Alpine Linux** and **Nginx**, optimized for high performance and low resource usage.
## <Icon name="Zap" color="warning" /> Quick Start
Run MIVO in a single command:
```bash
docker run -d \
--name mivo \
-p 8080:80 \
-e APP_KEY=base64:YOUR_GENERATED_KEY \
-e APP_ENV=production \
-v mivo_data:/var/www/html/app/Database \
-v mivo_config:/var/www/html/.env \
dyzulk/mivo:latest
```
Open your browser and navigate to `http://localhost:8080`.
**Initial Setup:**
If this is your first run, you will be redirected to the **Web Installer**. Follow the on-screen instructions to create the database and admin account.
## <Icon name="Wrench" color="primary" /> Docker Compose
For a more permanent setup, use `docker-compose.yml`:
```yaml
services:
mivo:
image: dyzulk/mivo:latest
container_name: mivo
restart: unless-stopped
ports:
- "8080:80"
environment:
- APP_ENV=production
- TZ=Asia/Jakarta
volumes:
- ./mivo-data:/var/www/html/app/Database
```
## <Icon name="Tags" color="info" /> Tags
- `latest`: Stable release (recommended).
- `edge`: Bleeding edge build from the `main` branch.
- `v1.x.x`: Specific released versions.
## <Icon name="Sliders" color="success" /> Environment Variables
| Variable | Description | Default |
| :--- | :--- | :--- |
| `APP_ENV` | Application environment (`production` or `local`). | `production` |
| `APP_DEBUG` | Enable debug mode (`true` or `false`). | `false` |
| `APP_KEY` | 32-character random string (base64). Auto-generated on first install if not provided. | |
| `TZ` | Timezone for the container. | `UTC` |
## <Icon name="Folder" color="primary" /> Volumes
Persist your data by mounting these paths:
- `/var/www/html/app/Database`: Stores the SQLite database and session files. **(Critical)**
- `/var/www/html/public/assets/img/logos`: Stores uploaded custom logos.
## <Icon name="Heart" color="danger" /> Support the Project
If you find MIVO useful, please consider supporting its development. Your contribution helps keep the project alive!
[![SociaBuzz Tribe](https://img.shields.io/badge/SociaBuzz-Tribe-green?style=for-the-badge&logo=sociabuzz&logoColor=white)](https://sociabuzz.com/dyzulkdev/tribe)
---
*Created with <Icon name="Heart" color="danger" /> by DyzulkDev*

View File

@@ -1,23 +0,0 @@
---
title: Introduction
---
# Introduction
Welcome to the MIVO Guide. This section will help you understand what MIVO is and how to get it running on your system.
## <Icon name="Zap" color="warning" /> What is MIVO?
MIVO is a modern, lightweight Mikrotik Voucher Management system. It is designed to be efficient, fast, and user-friendly, providing a seamless experience for Hotspot management.
## <Icon name="BookOpen" color="primary" /> Navigation
Explore the following sections to get started:
- **[Installation Guide](/guide/installation)**: Learn how to install MIVO on various platforms.
- **[Docker Guide](/guide/docker)**: The recommended way to run MIVO using containers.
- **[Manual](/manual/)**: Detailed instructions on how to use MIVO features.
## <Icon name="Heart" color="danger" /> Support
MIVO is an open-source project. If you find it useful, please consider supporting the development through [donations](https://sociabuzz.com/dyzulkdev/tribe) or [contributing](https://github.com/dyzulk/mivo) to the codebase.

View File

@@ -1,137 +0,0 @@
---
title: Installation Guide
---
# Installation Guide
This guide covers installation on various platforms. MIVO is designed to be lightweight and runs on almost any PHP-capable server.
## <Icon name="ClipboardList" color="primary" /> General Requirements {#requirements}
* **PHP**: 8.0 or higher
* **Extensions**: `sqlite3`, `openssl`, `mbstring`, `json`
* **Database**: SQLite (File based, no server needed)
---
## <Icon name="Container" color="info" /> Docker (Recommended)
The easiest way to run MIVO.
1. **Build & Run**
```bash
docker-compose up -d --build
```
Go to `http://localhost:8080`
2. **Manual Pull (Alternative)**
If you prefer to pull the image manually:
```bash
docker pull dyzulk/mivo:latest # Stable
docker pull dyzulk/mivo:v1.0.0 # Specific Version
docker pull dyzulk/mivo:edge # Bleeding Edge
```
*Note: The database is persisted in `app/Database` via volumes.*
---
## <Icon name="Server" color="success" /> Web Servers {#web-servers}
### 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.
---
## <Icon name="Smartphone" color="warning" /> Mobile / STB {#mobile-stb}
### 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.
---
## <Icon name="Globe" color="info" /> Shared Hosting {#shared-hosting}
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.
---
## <Icon name="Cloud" color="primary" /> VPS & Cloud {#vps-cloud}
### aaPanel
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.
---
## <Icon name="Settings" color="success" /> Post-Installation {#post-installation}
After setting up the server:
1. Copy `.env.example` to `.env` (if not already done).
2. **Install Application**
* **Option A: CLI**
Run `php mivo install` in your terminal.
* **Option B: Web Installer**
Open `http://your-domain.com/install` in your browser.

View File

@@ -1,33 +0,0 @@
# Icons Test Page
This page verifies that **Lucide Icons** and **Flag Icons** are correctly integrated.
## Lucide Icons
Using the global `<Icon />` component:
- <Icon name="Search" /> Search (Default)
- <Icon name="Settings" :size="32" stroke-width="3" color="danger" /> Settings (Semantic: danger)
- <Icon name="check-circle" color="success" /> Check Circle (Semantic: success)
- <Icon name="Github" color="primary" /> Github (Semantic: primary)
- <Icon name="Zap" color="warning" /> Zap (Semantic: warning)
- <Icon name="Info" color="info" /> Info (Semantic: info)
- <Icon name="HelpCircle" color="muted" /> Help (Semantic: muted)
## Flag Icons
Using standard `flag-icons` CSS classes:
- <span class="fi fi-id"></span> Indonesia
- <span class="fi fi-us"></span> United States
- <span class="fi fi-gb"></span> Great Britain
- <span class="fi fi-jp"></span> Japan
## Combined Usage
<div style="display: flex; gap: 10px; align-items: center; padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;">
<Icon name="Globe" />
<span>Selected Language:</span>
<span class="fi fi-id"></span>
<strong>Bahasa Indonesia</strong>
</div>

View File

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

View File

@@ -1,77 +0,0 @@
---
title: Panduan Docker
---
# Panduan Docker
Image Docker ini dibangun di atas **Alpine Linux** dan **Nginx**, dioptimalkan untuk performa tinggi dan penggunaan sumber daya rendah.
## <Icon name="Zap" color="warning" /> Mulai Cepat
Jalankan MIVO dengan satu perintah:
```bash
docker run -d \
--name mivo \
-p 8080:80 \
-e APP_KEY=base64:YOUR_GENERATED_KEY \
-e APP_ENV=production \
-v mivo_data:/var/www/html/app/Database \
-v mivo_config:/var/www/html/.env \
dyzulk/mivo:latest
```
Buka browser Anda dan navigasi ke `http://localhost:8080`.
**Pengaturan Awal:**
Jika ini adalah pertama kali dijalankan, Anda akan diarahkan ke **Web Installer**. Ikuti instruksi di layar untuk membuat database dan akun admin.
## <Icon name="Wrench" color="primary" /> Docker Compose
Untuk pengaturan yang lebih permanen, gunakan `docker-compose.yml`:
```yaml
services:
mivo:
image: dyzulk/mivo:latest
container_name: mivo
restart: unless-stopped
ports:
- "8080:80"
environment:
- APP_ENV=production
- TZ=Asia/Jakarta
volumes:
- ./mivo-data:/var/www/html/app/Database
```
## <Icon name="Tags" color="info" /> Tags
- `latest`: Rilis stabil (direkomendasikan).
- `edge`: Build terbaru dari branch `main` (bleeding edge).
- `v1.x.x`: Versi rilis spesifik.
## <Icon name="Sliders" color="success" /> Variabel Lingkungan
| Variabel | Deskripsi | Default |
| :--- | :--- | :--- |
| `APP_ENV` | Lingkungan aplikasi (`production` atau `local`). | `production` |
| `APP_DEBUG` | Aktifkan mode debug (`true` atau `false`). | `false` |
| `APP_KEY` | String acak ca 32-karakter (base64). Dibuat otomatis saat install pertama kali jika kosong. | |
| `TZ` | Zona waktu untuk container. | `UTC` |
## <Icon name="Folder" color="primary" /> Volume
Persist data Anda dengan me-mount path ini:
- `/var/www/html/app/Database`: Menyimpan database SQLite dan file sesi. **(Penting)**
- `/var/www/html/public/assets/img/logos`: Menyimpan logo kustom yang diupload.
## <Icon name="Heart" color="danger" /> Dukung Proyek Ini
Jika Anda merasa MIVO bermanfaat, harap pertimbangkan untuk mendukung pengembangannya. Kontribusi Anda sangat berarti untuk kelangsungan proyek ini!
[![SociaBuzz Tribe](https://img.shields.io/badge/SociaBuzz-Tribe-green?style=for-the-badge&logo=sociabuzz&logoColor=white)](https://sociabuzz.com/dyzulkdev/tribe)
---
*Dibuat dengan <Icon name="Heart" color="danger" /> oleh DyzulkDev*

View File

@@ -1,23 +0,0 @@
---
title: Pengenalan
---
# Pengenalan
Selamat datang di Panduan MIVO. Bagian ini akan membantu Anda memahami apa itu MIVO dan bagaimana cara menjalankannya di sistem Anda.
## <Icon name="Zap" color="warning" /> Apa itu MIVO?
MIVO adalah sistem Manajemen Voucher Mikrotik yang modern dan ringan. Sistem ini dirancang agar efisien, cepat, dan mudah digunakan, memberikan pengalaman yang mulus untuk pengelolaan Hotspot.
## <Icon name="BookOpen" color="primary" /> Navigasi
Jelajahi bagian berikut untuk memulai:
- **[Panduan Instalasi](/id/guide/installation)**: Pelajari cara menginstal MIVO di berbagai platform.
- **[Panduan Docker](/id/guide/docker)**: Cara yang direkomendasikan untuk menjalankan MIVO menggunakan container.
- **[Buku Manual](/id/manual/)**: Instruksi detail tentang cara menggunakan fitur-fitur MIVO.
## <Icon name="Heart" color="danger" /> Dukungan
MIVO adalah proyek open-source. Jika Anda merasa MIVO bermanfaat, harap pertimbangkan untuk mendukung pengembangannya melalui [donasi](https://sociabuzz.com/dyzulkdev/tribe) atau [berkontribusi](https://github.com/dyzulk/mivo) langsung ke kode program.

View File

@@ -1,121 +0,0 @@
---
title: Panduan Instalasi
---
# Panduan Instalasi
Panduan ini mencakup instalasi di berbagai platform. MIVO dirancang agar ringan dan berjalan di hampir semua server yang mendukung PHP.
## <Icon name="ClipboardList" color="primary" /> Persyaratan Umum {#persyaratan}
* **PHP**: 8.0 atau lebih tinggi
* **Ekstensi**: `sqlite3`, `openssl`, `mbstring`, `json`
* **Database**: SQLite (Berbasis file, tidak perlu server database)
---
## <Icon name="Container" color="info" /> Docker (Direkomendasikan) {#docker}
Cara termudah untuk menjalankan MIVO.
1. **Build & Run**
```bash
docker-compose up -d --build
```
Buka `http://localhost:8080`
2. **Manual Pull (Alternatif)**
Jika Anda lebih suka menarik image secara manual:
```bash
docker pull dyzulk/mivo:latest # Stable
docker pull dyzulk/mivo:v1.0.0 # Versi Spesifik
docker pull dyzulk/mivo:edge # Bleeding Edge
```
*Catatan: Database disimpan secara permanen di `app/Database` melalui volume.*
---
## <Icon name="Server" color="success" /> Web Server {#web-server}
### Nginx
Nginx tidak membaca `.htaccess`. Gunakan blok konfigurasi ini di blok `server` Anda:
```nginx
server {
listen 80;
server_name domain-anda.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; # Sesuaikan versi
}
location ~ /\.ht {
deny all;
}
}
```
---
## <Icon name="Smartphone" color="warning" /> Mobile / STB {#mobile-stb}
### Awebserver
1. Salin file MIVO ke folder `/htdocs`.
2. Arahkan document root ke `public` jika didukung, atau akses via `http://localhost:8080/public`.
3. Pastikan versi PHP kompatibel.
### Termux
1. Install PHP: `pkg install php`
2. Masuk ke direktori MIVO: `cd mivo`
3. Gunakan built-in server:
```bash
php mivo serve --host=0.0.0.0 --port=8080
```
4. Akses melalui browser.
---
## <Icon name="Globe" color="info" /> Shared Hosting {#shared-hosting}
Kebanyakan shared hosting menggunakan Apache atau OpenLiteSpeed, yang didukung penuh.
1. **Upload File**: Upload file MIVO ke `public_html/mivo` (atau folder subdomain).
2. **Arahkan Domain**:
* **Direkomendasikan**: Pergi ke "Domains" atau "Subdomains" di cPanel dan set **Document Root** agar menunjuk langsung ke folder `public/` (contoh: `public_html/mivo/public`).
* **Alternatif**: Jika tidak bisa mengubah Document Root, Anda bisa memindahkan isi `public/` ke root `public_html` dan memindahkan `app/`, `routes/`, dll satu level ke atas (tidak disarankan untuk keamanan).
3. **Versi PHP**: Pilih PHP 8.0+ di menu "Select PHP Version".
4. **Ekstensi**: Pastikan `sqlite3` dan `fileinfo` dicentang.
---
## <Icon name="Cloud" color="primary" /> VPS & Cloud {#vps-cloud}
### aaPanel
1. **Buat Website**: Tambah situs -> PHP-8.x.
2. **Direktori Situs**:
* Set **Running Directory** ke `/public`.
* Hapus centang "Anti-XSS" (terkadang memblokir penyimpanan konfigurasi).
3. **URL Rewrite**: Pilih template `thinkphp` atau `laravel` yang kompatibel.
4. **Izin File**: Chown user `www` ke direktori situs.
### PaaS Cloud (Railway / Render / Heroku)
> [!WARNING]
> MIVO menggunakan SQLite. Kebanyakan PaaS Cloud menggunakan **Ephemeral Filesytem** (Data hilang saat restart).
> Anda WAJIB menggunakan **Persistent Volume/Disk**.
---
## <Icon name="Settings" color="success" /> Pasca-Instalasi {#pasca-instalasi}
Setelah menyiapkan server:
1. Salin `.env.example` ke `.env` (jika belum dilakukan).
2. **Install Aplikasi**
* **Opsi A: CLI**
Jalankan `php mivo install` di terminal Anda.
* **Opsi B: Web Installer**
Buka `http://domain-anda.com/install` di browser.

View File

@@ -1,40 +0,0 @@
---
layout: home
hero:
name: "MIVO"
text: "Manajemen Voucher Mikrotik"
tagline: Modern, Ringan, dan Efisien. Dibuat untuk perangkat spesifikasi rendah dengan UX premium.
image:
light: /logo-m.svg
dark: /logo-m-dark.svg
alt: Logo MIVO
actions:
- theme: brand
text: Mulai Sekarang
link: /id/guide/installation
- theme: alt
text: Docker Image
link: /id/guide/docker
features:
- title: Core Ringan
details: Dibangun di atas framework MVC minimal (~50KB core) yang dioptimalkan untuk STB/Android.
- title: UI/UX Modern
details: Desain Glassmorphism segar menggunakan TailwindCSS dan Alpine.js.
- title: Docker Ready
details: Image resmi berbasis Alpine (~50MB) dengan Nginx dan Supervisor.
- title: Aman
details: Konfigurasi berbasis environment (.env), kredensial terenkripsi, dan sesi aman.
---
## Mengapa MIVO?
MIVO adalah **Sistem Manajemen Voucher Mikrotik** generasi baru, dirancang untuk memberikan pengalaman pengguna premium bahkan pada perangkat keras spesifikasi rendah.
### Sorotan Utama
- <Icon name="Zap" color="warning" /> **Sangat Cepat**: Tanpa framework berat seperti Laravel. Murni performa PHP 8.0+.
- <Icon name="Smartphone" color="info" /> **Mobile First**: Desain responsif sepenuhnya yang terasa seperti aplikasi native.
- <Icon name="Plug" color="success" /> **API First**: REST API bawaan dengan dukungan CORS untuk integrasi pihak ketiga.
- <Icon name="Wrench" color="primary" /> **Ramah Developer**: Arsitektur bersih, CLI tools (`php mivo`), dan mudah dikembangkan.

View File

@@ -1,29 +0,0 @@
---
title: Buku Panduan
---
# Buku Panduan
Selamat datang di **Buku Panduan MIVO**. Bagian ini mencakup aspek fungsional penggunaan aplikasi untuk mengelola jaringan Anda.
## <Icon name="BookOpen" color="primary" /> Topik
### <Icon name="Settings" color="info" /> [Pengaturan Global](/id/manual/settings/)
Konfigurasikan pengaturan tingkat sistem:
- **Manajemen Router**: Hubungkan dan kelola perangkat Mikrotik Anda.
- **Template Voucher**: Desain dan sesuaikan tata letak voucher Anda.
- **Logo Brand**: Unggah logo khusus untuk hotspot Anda.
- **API & CORS**: Ekspos data router Anda dengan aman ke aplikasi pihak ketiga.
### <Icon name="Activity" color="success" /> [Operasional Router](/id/manual/router/)
Kelola tugas harian router Anda setelah terhubung:
- **Dashboard**: Pantau traffic real-time dan kesehatan sistem.
- **Manajemen Hotspot**: Buat user, profil, dan generate voucher.
- **Laporan**: Lacak penjualan Anda dan lihat log sistem.
- **Tools Sistem**: Reboot, scheduler, dan manajemen DHCP.
---
> [!TIP]
> Panduan ini fokus pada **penggunaan** aplikasi. Untuk instalasi dan konfigurasi server, silakan merujuk ke [Panduan](/id/guide/installation).

View File

@@ -1,28 +0,0 @@
# Dashboard & Pemantauan
Dashboard berfungsi sebagai pusat kendali real-time untuk router Mikrotik Anda. Fitur ini mengumpulkan data penting dari API Mikrotik untuk memberikan gambaran instan tentang kesehatan jaringan Anda.
## <Icon name="LineChart" color="primary" /> Monitor Traffic Real-time
MIVO dilengkapi dengan monitor trafik langsung yang berkomunikasi langsung dengan interface Mikrotik Anda.
- **Pemilihan Interface**: Pilih interface fisik atau virtual apa pun (contoh: `ether1`, `wlan1`, `bridge-hotspot`).
- **Grafik Langsung**: Lihat trafik masuk dan keluar dalam satuan bits/sec atau bytes/sec.
- **Pelacakan Puncak**: Identifikasi lonjakan bandwidth dengan cepat.
## <Icon name="Cpu" color="warning" /> Sumber Daya Router
Pantau kesehatan fisik perangkat keras Mikrotik Anda:
- **Beban CPU**: Ditampilkan dalam persentase. Beban CPU yang tinggi mungkin menunjukkan perlunya pemutakhiran perangkat keras atau optimasi konfigurasi.
- **Memori**: Melacak RAM yang bebas vs total RAM.
- **Uptime**: Menunjukkan berapa lama router telah berjalan sejak reboot terakhir.
- **Penyimpanan**: Pantau ruang yang tersedia pada memori flash router Anda.
## <Icon name="Smartphone" color="success" /> Sesi Aktif
Ringkasan cepat pengguna yang saat ini terautentikasi:
- **Total Online**: Hitungan real-time pengguna yang sedang menggunakan hotspot.
- **IP/MAC Aktif**: Pantau perangkat yang terhubung secara garis besar.
> [!TIP]
> Biarkan Dashboard tetap terbuka selama jam sibuk untuk memantau kepadatan atau upaya akses yang tidak sah.

View File

@@ -1,40 +0,0 @@
# Manajemen Hotspot
Alat komprehensif untuk mengelola server Hotspot Mikrotik Anda, mulai dari pembuatan user hingga kontrol akses tingkat lanjut.
## <Icon name="Users" color="primary" /> User Hotspot {#users}
Halaman User (`/hotspot/users`) adalah database pusat untuk semua akun wifi Anda.
- **Pembuatan Manual**: Tambah user tunggal dengan username dan password spesifik.
- **Cetak Satuan**: Arahkan kursor ke user untuk melihat ikon cetak. Ini menggunakan template default yang ditetapkan pada profil mereka.
- **Cetak Massal**: Pilih beberapa user dan gunakan menu **Batch Actions** untuk mencetak semuanya sekaligus dalam satu halaman.
- **Monitor Status**: Lihat apakah user sedang login (Aktif) secara langsung di dalam daftar.
## <Icon name="Layers" color="info" /> Profil User {#profiles}
Profil User (`/hotspot/profiles`) menentukan aturan untuk setiap jenis voucher (contoh: 1 Jam, 1 Hari).
- **Rate Limit**: Kontrol kecepatan unggah dan unduh (contoh: `512k/1M`).
- **Shared Users**: Batasi berapa banyak perangkat yang dapat menggunakan akun yang sama secara bersamaan.
- **Validity**: Atur berapa lama akun tetap aktif setelah login pertama.
- **Harga**: Simpan harga jual untuk keperluan laporan.
## <Icon name="Ticket" color="success" /> Generator Voucher {#generate}
Generate ratusan voucher dalam hitungan detik (`/hotspot/generate`).
1. **Jumlah**: Pilih berapa banyak voucher yang akan dibuat.
2. **Server**: Pilih server hotspot mana yang dituju (biasanya `all`).
3. **User Mode**: Pilih antara `Username & Password` atau `Username = Password`.
4. **Prefix**: Tambahkan awalan (prefix) tetap pada setiap username yang dibuat.
## <Icon name="Zap" color="warning" /> Sesi Aktif & Cookies {#active}
Pantau dan kontrol koneksi saat ini (`/hotspot/active` dan `/hotspot/cookies`).
- **Kick User**: Putuskan sesi user yang sedang aktif secara paksa.
- **Cookies**: Kelola token 'remember me'. Menghapus cookie memaksa user untuk login kembali pada koneksi berikutnya.
## <Icon name="ShieldCheck" color="danger" /> Keamanan & Akses {#security}
Pengaturan lanjutan untuk akses jaringan tanpa persyaratan voucher biasa.
- **IP Bindings**: Lewati login hotspot untuk alamat MAC atau IP tertentu (contoh: untuk printer kantor atau server).
- **Walled Garden**: Izinkan akses ke situs web atau domain tertentu (contoh: portal bank Anda) bahkan sebelum user login.

View File

@@ -1,16 +0,0 @@
# Operasional Router
Operasional router adalah tugas-tugas spesifik dalam sesi setelah terhubung ke perangkat Mikrotik. Pengaturan ini bervariasi tergantung pada sesi router mana yang sedang Anda gunakan.
## <Icon name="Activity" color="primary" /> Ikhtisar
Setelah Anda memilih sesi dari sidebar, Anda mendapatkan akses ke alat-alat berikut:
- **[Dashboard](/id/manual/router/dashboard)**: Pemantauan traffic secara real-time.
- **[Manajemen Hotspot](/id/manual/router/hotspot)**: User, Profil, dan Voucher.
- **[Laporan](/id/manual/router/reports)**: Laporan penjualan dan log sistem.
- **[Tools Sistem](/id/manual/router/tools)**: Reboot, Scheduler, dan DHCP.
## <Icon name="Zap" color="warning" /> Sinkronisasi Real-time
MIVO berkomunikasi langsung dengan API Mikrotik Anda. Sebagian besar perubahan akan langsung diterapkan pada perangkat Anda.

View File

@@ -1,27 +0,0 @@
# Cetak Cepat (Quick Print)
Quick Print adalah modul khusus untuk penjualan voucher kecepatan tinggi. Fitur ini memungkinkan Anda untuk menampilkan paket tertentu yang ingin dijual dan mencetaknya dengan satu klik.
## <Icon name="BarChart2" color="primary" /> Dashboard Penjualan
Halaman utama (`/quick-print`) menampilkan "Paket" Anda dalam bentuk kartu besar yang dapat diklik.
- **Generate Instan**: Mengklik paket akan langsung memerintahkan router untuk membuat user baru.
- **Cetak Otomatis**: Setelah user dibuat, dialog cetak untuk voucher tersebut akan terbuka secara otomatis.
## <Icon name="Library" color="info" /> Manajemen Paket
Akses bagian **Kelola** (`/quick-print/manage`) untuk mempersonalisasi dashboard penjualan Anda.
### <Icon name="PlusCircle" color="success" /> Menambah Paket
- **Profil**: Pilih profil user Mikrotik.
- **Harga**: Tentukan harga tampilan (bisa berbeda dengan komentar di Mikrotik).
- **Template**: Tetapkan template voucher khusus untuk paket ini.
### <Icon name="Trash2" color="danger" /> Menghapus Paket
Menghapus paket di sini hanya menghapusnya dari dashboard Quick Print; **tidak** menghapus profil dari router Mikrotik Anda.
## <Icon name="Printer" color="warning" /> Alur Kerja
1. **Admin** memilih paket dari dashboard.
2. **MIVO** membuat akun username/password acak di Mikrotik.
3. **MIVO** mengambil template yang ditentukan dan mengirimkannya ke engine cetak browser.

View File

@@ -1,28 +0,0 @@
# Laporan & Log
Analisis performa bisnis Anda dan pantau aktivitas sistem melalui alat pelaporan yang mendetail.
## <Icon name="BarChart" color="success" /> Laporan Penjualan {#selling}
Halaman Laporan Penjualan (`/reports/selling`) memberikan rincian pendapatan Anda secara mendetail.
- **Garis Waktu**: Lihat penjualan berdasarkan hari, bulan, atau rentang tanggal khusus.
- **Detail**: Lihat profil mana yang terjual, stempel waktu (timestamp), dan harganya.
- **Ekspor**: (Jika tersedia) Ekspor data Anda untuk keperluan akuntansi offline.
## <Icon name="BarChart2" color="primary" /> Resume Penjualan {#resume}
Halaman Resume (`/reports/resume`) menawarkan pandangan tingkat tinggi yang disederhanakan tentang pertumbuhan bisnis Anda.
- **Total Pendapatan**: Gabungan pendapatan dari seluruh penjualan voucher.
- **Jumlah Voucher**: Total voucher yang terjual vs yang dibuat (generated).
- **Perbandingan Sesi**: Bandingkan performa di berbagai sesi router yang berbeda.
## <Icon name="ClipboardList" color="info" /> Log Sistem {#logs}
Pantau peristiwa real-time dari router Mikrotik Anda (`/reports/user-log`).
- **Peristiwa**: Lacak login user, logout, eksekusi script, dan error sistem.
- **Pemecahan Masalah**: Gunakan log ini untuk mengidentifikasi mengapa user tidak dapat terhubung atau kapan sesi terputus.
- **Live Stream**: Log diperbarui secara otomatis saat peristiwa terjadi di router.
> [!NOTE]
> MIVO mengambil log ini langsung dari circular buffer Mikrotik. Bersihkan log Anda pada terminal Mikrotik jika buffer menjadi terlalu besar.

View File

@@ -1,26 +0,0 @@
# Tools Sistem
Utilitas penting untuk memelihara, menjadwalkan, dan memantau fungsi inti router Mikrotik Anda.
## <Icon name="Network" color="info" /> DHCP Leases
Halaman DHCP Leases (`/network/dhcp`) memungkinkan Anda memantau semua perangkat yang terhubung ke jaringan LAN atau Hotspot Anda, bahkan sebelum mereka login.
- **Pelacakan Lease**: Lihat penetapan IP, alamat MAC, dan hostname dari perangkat yang terhubung.
- **Monitor Pra-Login**: Berguna untuk mengidentifikasi perangkat yang terhubung tetapi kesulitan mencapai halaman login hotspot.
## <Icon name="Clock" color="primary" /> Scheduler Router
MIVO menyediakan antarmuka lengkap (`/system/scheduler`) untuk mengelola script dan jadwal internal Mikrotik.
- **Daftar Tugas**: Lihat semua tugas terjadwal yang aktif dan dinonaktifkan di router Anda.
- **Kelola Tugas**: Tambah, ubah, atau hapus tugas langsung dari MIVO.
- **Otomatisasi**: Gunakan ini untuk eksekusi script berkala, seperti menghapus user yang kedaluwarsa atau menghasilkan laporan otomatis.
## <Icon name="Zap" color="warning" /> Tindakan Kritis
Picukan perintah tingkat sistem secara langsung dari antarmuka MIVO:
- **<Icon name="RefreshCw" color="info" /> Reboot**: Muat ulang perangkat keras Mikrotik Anda dengan aman.
- **<Icon name="Power" color="danger" /> Shutdown**: Matikan perangkat. Perhatikan bahwa Anda memerlukan akses fisik ke router untuk menghidupkannya kembali.
> [!WARNING]
> Tindakan ini segera dieksekusi pada router Mikrotik Anda. Pastikan tidak ada operasi kritis yang sedang berjalan sebelum melakukan reboot.

View File

@@ -1,15 +0,0 @@
# API & CORS
MIVO memungkinkan aplikasi eksternal untuk mengakses data router Anda secara aman melalui REST API.
## <Icon name="Unlock" color="warning" /> Kebijakan CORS
Untuk memungkinkan situs web Anda (misal: pengecek status) memanggil API MIVO, Anda harus memasukkan domain tersebut ke daftar putih (whitelist).
1. Buka **Pengaturan** > **API & CORS**.
2. Tambahkan URL situs web Anda (termasuk `https://`).
3. Simpan perubahan.
## <Icon name="Terminal" color="info" /> Dokumentasi
Dokumentasi API mendetail tersedia di bagian pengembang.

View File

@@ -1,16 +0,0 @@
# Pengaturan Global
Pengaturan global mengontrol instance MIVO Anda secara sistem lunak. Ini adalah konfigurasi tingkat sistem yang tidak bergantung pada koneksi Mikrotik tertentu.
## <Icon name="Settings" color="primary" /> Ikhtisar
Akses pengaturan ini dari menu kanan atas atau sidebar utama.
- **[Router](/id/manual/settings/routers)**: Kelola koneksi Mikrotik Anda.
- **[Template](/id/manual/settings/templates)**: Kustomisasi desain voucher.
- **[Logo](/id/manual/settings/logos)**: Unggah logo brand.
- **[API & CORS](/id/manual/settings/api-cors)**: Konfigurasi akses API.
## <Icon name="Shield" color="info" /> Administrasi
Anda juga dapat mengelola administrator MIVO dan perilaku sistem umum (Zona Waktu, Bahasa) di sini.

View File

@@ -1,29 +0,0 @@
# Logo Brand
Manajemen Logo memungkinkan Anda untuk mengelola galeri aset brand yang digunakan untuk kustomisasi voucher Hotspot Mikrotik dan antarmuka aplikasi.
## <Icon name="Image" color="primary" /> Tujuan
Dengan mengunggah logo bisnis Anda di sini, Anda dapat menciptakan pengalaman brand yang profesional bagi pelanggan Anda. Logo-logo ini disimpan terpusat dan dapat dipanggil secara dinamis di berbagai bagian MIVO.
## <Icon name="UploadCloud" color="success" /> Proses Mengunggah
1. Buka **Pengaturan** > **Logo**.
2. **Drag & Drop** atau klik area unggah untuk memilih file Anda.
3. **Format yang Didukung**: PNG, JPG, SVG, dan GIF didukung. Disarankan menggunakan PNG transparan atau SVG untuk tampilan terbaik di voucher.
## <Icon name="Hash" color="info" /> Logo ID & Fitur Copy
Setiap logo yang Anda unggah akan diberikan **Short ID** yang unik (contoh: `lg01`, `logo_wifi`).
### <Icon name="Copy" color="primary" /> Cara Menggunakan Copy ID
Di galeri logo, arahkan kursor ke logo apapun untuk memunculkan tombol **Copy ID**.
- **Fungsi**: Mengklik tombol ini akan menyalin ID unik tersebut ke clipboard Anda.
- **Integrasi**: Gunakan ID ini di dalam **Voucher Template Editor** (contoh: `&#123;&#123;logo:lg01&#125;&#125;`) untuk menampilkan logo spesifik tersebut pada voucher yang dicetak.
## <Icon name="Trash2" color="danger" /> Manajemen
Anda dapat menghapus logo yang tidak digunakan kapan saja.
> [!WARNING]
> Menghapus logo akan menyebabkan template voucher yang mereferensikan ID-nya menampilkan gambar yang rusak (placeholder).

View File

@@ -1,28 +0,0 @@
# Manajemen Router
Untuk mulai mengelola Mikrotik Anda, pertama-tama Anda perlu menghubungkannya ke MIVO.
## <Icon name="PlusCircle" color="success" /> Menghubungkan Router
1. Buka **Pengaturan** > **Router**.
2. Klik **Tambah Router**.
3. Isi detailnya:
- **Nama Sesi**: Identitas unik untuk koneksi ini.
- **Alamat IP**: IP Mikrotik atau nama DNS Anda.
- **Username/Password**: Akun API Mikrotik Anda.
- **Port**: Biasanya 8728 (API).
## <Icon name="Activity" color="info" /> Status Koneksi
MIVO akan mencoba terhubung ke router secara real-time. Jika status berwarna hijau, Anda siap memulai pengelolaan!
## <Icon name="Database" color="warning" /> Backup & Restore
Lindungi konfigurasi Anda dengan membuat cadangan (backup) database MIVO Anda.
- **Backup**: Membuat file JSON yang berisi semua sesi router dan pengaturan global.
- **Restore**: Unggah file cadangan MIVO yang telah disimpan sebelumnya untuk memulihkan data Anda.
> [!CAUTION]
> Melakukan restore akan menimpa pengaturan dan sesi Anda yang ada saat ini.

View File

@@ -1,20 +0,0 @@
# Pengaturan Sistem
Kelola akun administrator MIVO Anda dan perilaku aplikasi secara global.
## <Icon name="UserCheck" color="primary" /> Akun Admin
Ubah kredensial administrator MIVO Anda untuk menjaga keamanan sistem.
- **Username**: Nama yang digunakan untuk login.
- **Password**: Kata sandi aman untuk akses.
## <Icon name="Globe" color="info" /> Konfigurasi Global
Sesuaikan pengaturan lingkungan untuk seluruh aplikasi:
- **Bahasa**: Pilih bahasa antarmuka pilihan Anda (Inggris/Indonesia).
- **Zona Waktu**: Atur waktu lokal untuk laporan dan log yang akurat.
- **Mata Uang**: Tentukan simbol mata uang yang digunakan dalam voucher dan laporan.
## <Icon name="ShieldAlert" color="warning" /> Keamanan
MIVO menggunakan autentikasi berbasis sesi. Pastikan Anda melakukan logout saat menggunakan terminal publik.

View File

@@ -1,14 +0,0 @@
# Template Voucher
MIVO memiliki engine template yang mumpuni untuk membuat voucher yang cantik dan siap cetak.
## <Icon name="FileCode" color="primary" /> Kustomisasi Template
Template menggunakan HTML dan placeholder khusus untuk menampilkan data voucher.
- **Variabel**: `{{username}}`, `{{password}}`, `{{price}}`, dll.
- **Preview**: Uji template Anda secara instan dari editor.
## <Icon name="Printer" color="info" /> Cetak Cepat
Setelah template disimpan, template tersebut akan tersedia di menu **Cetak Cepat** (Quick Print) di dalam sesi router Anda.

View File

@@ -1,40 +0,0 @@
---
layout: home
hero:
name: "MIVO"
text: "Mikrotik Voucher Management"
tagline: Modern, Lightweight, and Efficient. Built for low-end devices with premium UX.
image:
light: /logo-m.svg
dark: /logo-m-dark.svg
alt: MIVO Logo
actions:
- theme: brand
text: Get Started
link: /guide/installation
- theme: alt
text: Docker Image
link: /guide/docker
features:
- title: Lightweight Core
details: Built on a custom minimal MVC framework (~50KB core) optimized for STB/Android.
- title: Modern UI/UX
details: Fresh Glassmorphism design system using TailwindCSS and Alpine.js.
- title: Docker Ready
details: Official Alpine-based image (~50MB) with Nginx and Supervisor.
- title: Secure
details: Environment-based config (.env), encrypted credentials, and secure sessions.
---
## Why MIVO?
MIVO is a next-generation **Mikrotik Voucher Management System**, engineered to deliver premium user experience even on low-end hardware.
### Key Highlights
- <Icon name="Zap" color="warning" /> **Blazing Fast**: No heavy frameworks like Laravel. Pure PHP 8.0+ performance.
- <Icon name="Smartphone" color="info" /> **Mobile First**: Fully responsive design that feels like a native app.
- <Icon name="Plug" color="success" /> **API First**: Built-in REST API with CORS support for 3rd party integrations.
- <Icon name="Wrench" color="primary" /> **Developer Friendly**: Clean architecture, CLI tools (`php mivo`), and easy extension.

View File

@@ -1,29 +0,0 @@
---
title: User Manual
---
# User Manual
Welcome to the **MIVO User Manual**. This section covers the functional aspects of using the application to manage your network.
## <Icon name="BookOpen" color="primary" /> Topics
### <Icon name="Settings" color="info" /> [Global Settings](/manual/settings/)
Configure your system-wide settings:
- **Managing Routers**: Connect and manage your Mikrotik devices.
- **Voucher Templates**: Design and customize your voucher layouts.
- **Brand Logos**: Upload custom logos for your hotspot.
- **API & CORS**: Securely expose your router data to 3rd party apps.
### <Icon name="Activity" color="success" /> [Router Operations](/manual/router/)
Manage your everyday router tasks after connecting:
- **Dashboard**: Monitor real-time traffic and system health.
- **Hotspot Management**: Create users, profiles, and generate vouchers.
- **Reports**: Track your sales and view system logs.
- **System Tools**: Reboot, scheduler, and DHCP management.
---
> [!TIP]
> This manual focuses on **using** the application. For installation and server configuration, please refer to the [Guide](/guide/installation).

View File

@@ -1,28 +0,0 @@
# Dashboard & Monitoring
The Dashboard provides a real-time command center for your Mikrotik router. It aggregates critical data from the Mikrotik API to give you an immediate overview of your network's health.
## <Icon name="LineChart" color="primary" /> Real-time Traffic Monitor
MIVO features a live traffic monitor that communicates directly with your Mikrotik interfaces.
- **Interface Selection**: Choose any physical or virtual interface (e.g., `ether1`, `wlan1`, `bridge-hotspot`).
- **Live Graphing**: View incoming and outgoing traffic in bits/sec or bytes/sec.
- **Peak Tracking**: Quickly identify bandwidth spikes.
## <Icon name="Cpu" color="warning" /> Router Resources
Monitor the physical health of your Mikrotik hardware:
- **CPU Load**: Displayed as a percentage. High CPU load may indicate the need for hardware upgrades or configuration optimization.
- **Memory**: Tracks free vs. total RAM.
- **Uptime**: Shows how long the router has been running since the last reboot.
- **Disk/Storage**: Monitor available space on your router's flash memory.
## <Icon name="Smartphone" color="success" /> Active Sessions
A quick summary of currently authenticated users:
- **Total Online**: Real-time count of users currently using the hotspot.
- **Active IP/MAC**: Monitor connected devices on a high level.
> [!TIP]
> Keep the Dashboard open during peak hours to monitor for congestion or unauthorized access attempts.

View File

@@ -1,40 +0,0 @@
# Hotspot Management
Comprehensive tools to manage your Mikrotik Hotspot server, from user creation to advanced access control.
## <Icon name="Users" color="primary" /> Hotspot Users {#users}
The Users page (`/hotspot/users`) is the central database for all your wifi accounts.
- **Manual Creation**: Add individual users with specific usernames and passwords.
- **Individual Printing**: Hover over a user to see the print icon. It uses the default template assigned to their profile.
- **Batch Printing**: Select multiple users and use the **Batch Actions** menu to print them all at once on a single page.
- **Status Monitor**: See if a user is currently logged in (Active) directly in the list.
## <Icon name="Layers" color="info" /> User Profiles {#profiles}
User Profiles (`/hotspot/profiles`) define the rules for each type of voucher (e.g., 1 Hour, 1 Day).
- **Rate Limit**: Control upload and download speeds (e.g., `512k/1M`).
- **Shared Users**: Limit how many devices can use the same account simultaneously.
- **Validity**: Set how long the account remains active after the first login.
- **Price**: Store the selling price for reporting purposes.
## <Icon name="Ticket" color="success" /> Voucher Generator {#generate}
Generate hundreds of vouchers in seconds (`/hotspot/generate`).
1. **Quantity**: Choose how many vouchers to create.
2. **Server**: Select which hotspot server to target (usually `all`).
3. **User Mode**: Choose between `Username & Password` or `Username = Password`.
4. **Prefix**: Add a constant prefix to every generated username.
## <Icon name="Zap" color="warning" /> Active Sessions & Cookies {#active}
Monitor and control current connections (`/hotspot/active` and `/hotspot/cookies`).
- **Kick User**: Forcefully disconnect an active user session.
- **Cookies**: Manage 'remember me' tokens. Deleting a cookie forces the user to log in again on their next connection.
## <Icon name="ShieldCheck" color="danger" /> Security & Access {#security}
Advanced settings for network access without typical voucher requirements.
- **IP Bindings**: Bypass the hotspot login for specific MAC or IP addresses (e.g., for office printers or servers).
- **Walled Garden**: Allow access to specific websites or domains (e.g., your bank's portal) even before users log in.

View File

@@ -1,16 +0,0 @@
# Router Operations
Router operations are session-specific tasks performed once connected to a Mikrotik device. These settings vary depending on which router session you are currently in.
## <Icon name="Activity" color="primary" /> Overview
Once you select a session from the sidebar, you gain access to these tools:
- **[Dashboard](/manual/router/dashboard)**: Real-time traffic monitoring.
- **[Hotspot Management](/manual/router/hotspot)**: Users, Profiles, and Vouchers.
- **[Reports](/manual/router/reports)**: Sales reports and system logs.
- **[System Tools](/manual/router/tools)**: Reboot, Scheduler, and DHCP.
## <Icon name="Zap" color="warning" /> Real-time Sync
MIVO communicates directly with your Mikrotik API. Most changes take effect immediately on your device.

View File

@@ -1,27 +0,0 @@
# Quick Print
Quick Print is a specialized module for high-speed voucher vending. It allows you to display only the specific packages you want to sell and print them with a single click.
## <Icon name="BarChart2" color="primary" /> Vending Dashboard
The main page (`/quick-print`) displays your "Packets" as large, clickable cards.
- **Instant Generation**: Clicking a packet immediately requests the router to create a new user.
- **Auto-Print**: Once the user is created, the print dialog for that voucher opens automatically.
## <Icon name="Library" color="info" /> Packet Management
Access the **Manage** section (`/quick-print/manage`) to customize your vending dashboard.
### <Icon name="PlusCircle" color="success" /> Adding Packets
- **Profile**: Choose a Mikrotik user profile.
- **Price**: Set the display price (can differ from Mikrotik comment).
- **Template**: Assign a specific voucher template for this packet.
### <Icon name="Trash2" color="danger" /> Removing Packets
Deleting a packet here only removes it from the Quick Print dashboard; it does **not** delete the profile from your Mikrotik router.
## <Icon name="Printer" color="warning" /> Workflow
1. **Admin** chooses a packet from the dashboard.
2. **MIVO** creates a random username/password account on the Mikrotik.
3. **MIVO** fetches the assigned template and sends it to the browser's print engine.

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