25 Commits
v1.0.0 ... main

Author SHA1 Message Date
dyzulk
f3a01aa973 feat: v1.2.3 release - status bars, cors fix, update checker 2026-01-19 13:59:46 +07:00
dyzulk
ca1fef86bd chore: bump version to v1.2.3 - Fix CORS & Update Notification 2026-01-19 13:29:20 +07:00
dyzulk
51ca6d3669 fix: docker permissions & bump v1.2.2 2026-01-19 11:09:42 +07:00
dyzulk
9cee55c05a chore: bump version to v1.2.1 2026-01-19 10:04:47 +07:00
dyzulk
a0e8c097f7 chore: track .gitkeep in plugins folder 2026-01-19 07:07:33 +07:00
dyzulk
aee64ac137 chore: stop tracking plugins folder content 2026-01-19 07:05:35 +07:00
dyzulk
bbda8eaca1 ci: add ai release notes generator to workflow 2026-01-19 06:56:44 +07:00
dyzulk
18a525e438 chore: bump version to v1.2.0, cleanup repo, and update docs refs 2026-01-18 23:29:04 +07:00
MivoDev
6c92985707 Bump version to v1.1.1 2026-01-18 14:00:55 +07:00
MivoDev
d4691bc700 Cleanup: Remove legacy docs deployment workflow 2026-01-18 12:08:51 +07:00
MivoDev
74b258b12d feat: add GitHub Actions workflow for Docker image build and publish to GHCR and Docker Hub. 2026-01-18 12:07:31 +07:00
MivoDev
d5939dc5a2 Initial commit of documentation source 2026-01-18 11:07:35 +07:00
MivoDev
c95c8b08ea feat: Implement a core plugin system, integrate flag icon assets, and establish a GitHub release workflow. 2026-01-18 11:00:36 +07:00
dyzulk
b245f31236 feat: Implement an installation process including a setup UI, database migrations, and admin account creation. 2026-01-17 13:57:08 +07:00
dyzulk
e8ffea2c58 fix: Set HOME environment variable at script start and add explicit permissions for node_modules and the tailwindcss binary. 2026-01-17 13:49:58 +07:00
dyzulk
a4d0233386 fix: Resolve Git ownership, Composer, and npm environment issues in the deployment script and add cleanup for temporary files. 2026-01-17 13:47:09 +07:00
dyzulk
95ca189679 feat: Add aaPanel deployment script and exclude it from git archives. 2026-01-17 13:42:16 +07:00
dyzulk
5b0b6de2dc Chore: Bump version to v1.1.0 and implement automated release system 2026-01-17 13:01:05 +07:00
dyzulk
64609a5821 feat: implement Logo model for managing logo uploads, storage, and database persistence. 2026-01-17 04:05:51 +07:00
dyzulk
08960b540f refactor(docker): update aapanel port to 8085 to avoid conflicts 2026-01-17 03:39:44 +07:00
dyzulk
d8c1a779b8 feat: Implement aaPanel Docker deployment support with new guides and configuration files, including a minor port adjustment in the general Docker README. 2026-01-17 03:37:11 +07:00
dyzulk
4968246911 feat: Add Docker Compose template and English/Indonesian aaPanel deployment guides for the Mivo application. 2026-01-17 03:08:14 +07:00
dyzulk
ae65ab30fa feat: add aaPanel Docker deployment support and documentation 2026-01-17 03:03:23 +07:00
dyzulk
6eb6bbb359 feat: add initial MIVO documentation, including English and Indonesian guides and main pages, and update project descriptions. 2026-01-16 16:05:52 +07:00
dyzulk
7a0c6cb5c3 feat: Implement initial VitePress documentation site with multi-language navigation and Open Graph metadata. 2026-01-16 15:55:12 +07:00
726 changed files with 29577 additions and 79064 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
@@ -10,3 +11,4 @@ docs/
app/Database/*.sqlite app/Database/*.sqlite
public/assets/img/logos/* public/assets/img/logos/*
!public/assets/img/logos/.gitignore !public/assets/img/logos/.gitignore
CNAME

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

12
.gitattributes vendored
View File

@@ -2,11 +2,21 @@
/docs export-ignore /docs export-ignore
/.github export-ignore /.github export-ignore
/docker export-ignore /docker export-ignore
/CNAME export-ignore
/.gitattributes export-ignore /.gitattributes export-ignore
/.gitignore export-ignore /.gitignore export-ignore
/.dockerignore export-ignore /.dockerignore export-ignore
/.env.example export-ignore /.env.example export-ignore
/deploy_package.tar.gz export-ignore /package.json export-ignore
/package-lock.json export-ignore
/tailwind.config.js export-ignore
/src export-ignore
/Dockerfile export-ignore
/docker-compose.yml export-ignore
/DOCKER_README.md export-ignore /DOCKER_README.md export-ignore
/build_release.ps1 export-ignore
/deploy.ps1 export-ignore
/serve.bat export-ignore
/phpstan.neon export-ignore /phpstan.neon export-ignore
/phpunit.xml export-ignore /phpunit.xml export-ignore
/aapanel_deploy.sh export-ignore

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)

76
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Create Release
on:
push:
tags:
- 'v*.*.*'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, ctype, iconv, sqlite3, openssl
coverage: none
- name: Get version from tag
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Create Release Directory
run: |
mkdir release_temp
# Export source using git archive (respects .gitattributes)
git archive --format=tar HEAD | tar -x -C release_temp
- name: Install 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
run: |
cd release_temp
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
run: |
cd release_temp
zip -r ../mivo-v${{ steps.get_version.outputs.VERSION }}.zip .
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: mivo-v${{ steps.get_version.outputs.VERSION }}.zip
body_path: .github/release_notes.md
generate_release_notes: false
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

16
.gitignore vendored
View File

@@ -17,14 +17,20 @@ Thumbs.db
# Build Artifacts & Deployments # Build Artifacts & Deployments
/deploy_package.tar.gz /deploy_package.tar.gz
/mivo_backup_*.mivo /mivo_backup_*.mivo
/mivo-*.zip
# Secrets and Environment # Secrets and Environment
.env .env
# VitePress
docs/.vitepress/dist
docs/.vitepress/cache
# Build Scripts & Artifacts # Build Scripts & Artifacts
build_release.ps1 build_release.ps1
*.zip deploy.ps1
.github/release_notes.md
# User Uploads
/public/uploads/*
!/public/uploads/.gitignore
# Plugins
/plugins/*
!/plugins/.gitkeep

1
CNAME
View File

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

View File

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

@@ -29,8 +29,15 @@ RUN mkdir -p /var/www/html/app/Database && \
chown -R www-data:www-data /var/www/html && \ chown -R www-data:www-data /var/www/html && \
chmod -R 755 /var/www/html chmod -R 755 /var/www/html
# Copy Entrypoint
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Expose port # Expose port
EXPOSE 80 EXPOSE 80
# Start Supervisor (which starts Nginx & PHP-FPM) # Use Entrypoint
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Start Supervisor
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

View File

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

82
aapanel_deploy.sh Normal file
View File

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

View File

@@ -3,17 +3,17 @@ namespace App\Config;
class SiteConfig { class SiteConfig {
const APP_NAME = 'MIVO'; const APP_NAME = 'MIVO';
const APP_VERSION = 'v1.0'; const APP_VERSION = 'v1.2.3';
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

@@ -32,7 +32,8 @@ class ApiController extends Controller {
$configModel = new Config(); $configModel = new Config();
$session = $configModel->getSessionById($id); $session = $configModel->getSessionById($id);
if ($session && !empty($session['password'])) { if ($session && !empty($session['password'])) {
$pass = EncryptionHelper::decrypt($session['password']); // Config::getSessionById already decrypts the password
$pass = $session['password'];
} }
} }

View File

@@ -10,7 +10,7 @@ use App\Core\Middleware;
class DashboardController extends Controller { class DashboardController extends Controller {
public function __construct() { public function __construct() {
Middleware::auth(); // Auth handled by Router Middleware
} }
public function index($session) { public function index($session) {
@@ -101,6 +101,7 @@ class DashboardController extends Controller {
'hotspot_users' => 'Hotspot Users', 'hotspot_users' => 'Hotspot Users',
'hotspot_users' => 'Hotspot Users', 'hotspot_users' => 'Hotspot Users',
], ],
'reload_interval' => $creds['reload'] ?? 5, // Default 5s if not set
'interface' => $creds['interface'] ?? 'ether1' 'interface' => $creds['interface'] ?? 'ether1'
]; ];
// Pass Users Link (Optional: could be part of layout or card link) // Pass Users Link (Optional: could be part of layout or card link)
@@ -108,7 +109,9 @@ class DashboardController extends Controller {
return $this->view('dashboard', $data); return $this->view('dashboard', $data);
} else { } else {
echo "Connection Failed to " . $creds['ip']; \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/'));
exit;
} }
} }
} }

View File

@@ -26,7 +26,10 @@ class DhcpController extends Controller
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) { if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
// Fetch DHCP Leases // Fetch DHCP Leases
$leases = $API->comm("/ip/dhcp-server/lease/print"); $leases = $API->comm("/ip/dhcp-server/lease/print");
$API->disconnect(); } else {
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $config['ip_address']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
// Add index for viewing // Add index for viewing

View File

@@ -34,8 +34,9 @@ class GeneratorController extends Controller {
$this->view('hotspot/generate', $data); $this->view('hotspot/generate', $data);
} else { } else {
// Handle connection error (flash message ideally, but for now redirect or show error) \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
echo "Connection failed to " . $creds['ip']; header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
} }

View File

@@ -25,6 +25,7 @@ class HotspotController extends Controller {
$userId = $session; // For view context $userId = $session; // For view context
$users = []; $users = [];
$servers = [];
$error = null; $error = null;
$API = new RouterOSAPI(); $API = new RouterOSAPI();
@@ -40,17 +41,20 @@ class HotspotController extends Controller {
// Get all hotspot users // Get all hotspot users
$users = $API->comm("/ip/hotspot/user/print"); $users = $API->comm("/ip/hotspot/user/print");
// Get active users to mark status (optional, can be done later for optimization) // Get servers for dropdown
// $active = $API->comm("/ip/hotspot/active/print"); $servers = $API->comm("/ip/hotspot/server/print");
$API->disconnect(); $API->disconnect();
} else { } else {
$error = "Connection Failed to " . $creds['ip']; \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
$data = [ $data = [
'session' => $session, 'session' => $session,
'users' => $users, 'users' => $users,
'servers' => $servers,
'error' => $error 'error' => $error
]; ];
@@ -389,7 +393,9 @@ class HotspotController extends Controller {
$items = $API->comm("/ip/hotspot/active/print"); $items = $API->comm("/ip/hotspot/active/print");
$API->disconnect(); $API->disconnect();
} else { } else {
$error = "Connection Failed to " . $creds['ip']; \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
$data = [ $data = [
@@ -451,7 +457,9 @@ class HotspotController extends Controller {
$items = $API->comm("/ip/hotspot/host/print"); $items = $API->comm("/ip/hotspot/host/print");
$API->disconnect(); $API->disconnect();
} else { } else {
$error = "Connection Failed to " . $creds['ip']; \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
$data = [ $data = [
@@ -484,7 +492,9 @@ class HotspotController extends Controller {
$items = $API->comm("/ip/hotspot/ip-binding/print"); $items = $API->comm("/ip/hotspot/ip-binding/print");
$API->disconnect(); $API->disconnect();
} else { } else {
$error = "Connection Failed to " . $creds['ip']; \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
$data = [ $data = [
@@ -606,7 +616,9 @@ class HotspotController extends Controller {
$items = $API->comm("/ip/hotspot/walled-garden/ip/print"); $items = $API->comm("/ip/hotspot/walled-garden/ip/print");
$API->disconnect(); $API->disconnect();
} else { } else {
$error = "Connection Failed to " . $creds['ip']; \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
$data = [ $data = [
@@ -837,8 +849,9 @@ class HotspotController extends Controller {
$templateContent = $tpl['content']; $templateContent = $tpl['content'];
$viewName = 'print/custom'; $viewName = 'print/custom';
} else { } else {
// Fallback if ID invalid \App\Helpers\FlashHelper::set('error', 'Template Not Found', 'The selected print template could not be found.');
$currentTemplate = 'default'; header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/hotspot/users'));
exit;
} }
} }

View File

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

@@ -44,7 +44,10 @@ class LogController extends Controller
$logs = array_reverse($logs); $logs = array_reverse($logs);
} }
$API->disconnect(); } else {
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $config['ip_address']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
exit;
} }
return $this->view('reports/user_log', [ return $this->view('reports/user_log', [

View File

@@ -21,6 +21,21 @@ class ProfileController extends Controller
// Use default port 8728 if not specified // Use default port 8728 if not specified
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) { if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
$profiles = $API->comm('/ip/hotspot/user/profile/print'); $profiles = $API->comm('/ip/hotspot/user/profile/print');
// Fetch Pools & Queues for the Modal Form
$pools = $API->comm('/ip/pool/print');
$simple = $API->comm('/queue/simple/print');
$tree = $API->comm('/queue/tree/print');
$queues = [];
foreach ($simple as $q) {
if(isset($q['name'])) $queues[] = $q['name'];
}
foreach ($tree as $q) {
if(isset($q['name'])) $queues[] = $q['name'];
}
sort($queues);
$API->disconnect(); $API->disconnect();
// Process profiles to add metadata from on-login script // Process profiles to add metadata from on-login script
@@ -33,15 +48,14 @@ class ProfileController extends Controller
$this->view('hotspot/profiles/index', [ $this->view('hotspot/profiles/index', [
'session' => $session, 'session' => $session,
'profiles' => $profiles, 'profiles' => $profiles,
'pools' => $pools,
'queues' => $queues,
'title' => 'User Profiles' 'title' => 'User Profiles'
]); ]);
} else { } else {
$this->view('hotspot/profiles/index', [ \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
'session' => $session, header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
'profiles' => [], exit;
'error' => 'Connection Failed to ' . $creds['ip'],
'title' => 'User Profiles'
]);
} }
} }

View File

@@ -14,14 +14,9 @@ class PublicStatusController extends Controller {
// View: Show Search Page // View: Show Search Page
public function index($session) { public function index($session) {
// Just verify session existence to display Hotspot Name // Just verify session existence to display Hotspot Name
// Session verified by RouterCheckMiddleware
$configModel = new Config(); $configModel = new Config();
$creds = $configModel->getSession($session); $creds = $configModel->getSession($session);
if (!$creds) {
// If session invalid, maybe show 404 or generic error
echo "Session not found.";
return;
}
$data = [ $data = [
'session' => $session, 'session' => $session,
@@ -92,27 +87,31 @@ class PublicStatusController extends Controller {
if (!empty($user)) { if (!empty($user)) {
$u = $user[0]; $u = $user[0];
// DEBUG: Log the user data to see raw values // --- SECURITY CHECK: Hide Unused Vouchers (UNLESS ACTIVE) ---
error_log("Status Debug: " . json_encode($u));
// --- SECURITY CHECK: Hide Unused Vouchers ---
$uptimeRaw = $u['uptime'] ?? '0s'; $uptimeRaw = $u['uptime'] ?? '0s';
$bytesIn = intval($u['bytes-in'] ?? 0); $bytesIn = intval($u['bytes-in'] ?? 0);
$bytesOut = intval($u['bytes-out'] ?? 0); $bytesOut = intval($u['bytes-out'] ?? 0);
if (($uptimeRaw === '0s' || empty($uptimeRaw)) && ($bytesIn + $bytesOut) === 0) { // Check if active first
$active = $api->comm("/ip/hotspot/active/print", [
"?user" => $code
]);
$isActive = !empty($active);
// If Empty Stats AND Not Active => Hide (It's an unused new voucher)
// If Empty Stats BUT Active => Show! (It's a fresh session)
if (!$isActive && ($uptimeRaw === '0s' || empty($uptimeRaw)) && ($bytesIn + $bytesOut) === 0) {
$api->disconnect(); $api->disconnect();
echo json_encode(['success' => false, 'message' => 'Voucher Not Found']); echo json_encode(['success' => false, 'message' => 'Voucher Not Found']);
return; return;
} }
// --- SECURITY CHECK: Hide Unlimited Members --- // --- SECURITY CHECK: Hide Unlimited Members (UNLESS ACTIVE) ---
$limitBytes = isset($u['limit-bytes-total']) ? intval($u['limit-bytes-total']) : 0; $limitBytes = isset($u['limit-bytes-total']) ? intval($u['limit-bytes-total']) : 0;
$limitUptime = $u['limit-uptime'] ?? '0s'; $limitUptime = $u['limit-uptime'] ?? '0s';
if ($limitBytes === 0 && ($limitUptime === '0s' || empty($limitUptime))) { if (!$isActive && $limitBytes === 0 && ($limitUptime === '0s' || empty($limitUptime))) {
// Option: Allow checking them but show minimalistic info, or hide. // Hide unlimited members if they are offline to prevent enumeration
// Sticking to original logic: Hide them.
$api->disconnect(); $api->disconnect();
echo json_encode(['success' => false, 'message' => 'Voucher Not Found']); echo json_encode(['success' => false, 'message' => 'Voucher Not Found']);
return; return;
@@ -181,11 +180,9 @@ class PublicStatusController extends Controller {
// 2. CHECK ACTIVE OVERRIDE // 2. CHECK ACTIVE OVERRIDE
// If user is conceptually valid (or even if limited?), check if they are currently active // If user is conceptually valid (or even if limited?), check if they are currently active
// Because they might be active BUT expiring soon, or active BUT over quota (if server hasn't kicked them yet) // Because they might be active BUT expiring soon, or active BUT over quota (if server hasn't kicked them yet)
$active = $api->comm("/ip/hotspot/active/print", [ // $active already fetched above in Security Check
"?user" => $code
]); if ($isActive) {
if (!empty($active)) {
$status = 'active'; $status = 'active';
$statusLabel = 'Active (Online)'; $statusLabel = 'Active (Online)';
} }

View File

@@ -19,7 +19,14 @@ class QuickPrintController extends Controller {
// Dashboard: List Cards // Dashboard: List Cards
public function index($session) { public function index($session) {
$qpModel = new QuickPrintModel(); $qpModel = new QuickPrintModel();
$packages = $qpModel->getAllBySession($session);
$configModel = new Config();
$creds = $configModel->getSession($session);
$routerId = $creds['id'] ?? null;
// If no ID (Legacy), fallback to empty list or handle gracefully.
// For now, we assume ID exists as per migration plan.
$packages = $routerId ? $qpModel->getAllByRouterId($routerId) : [];
$data = [ $data = [
'session' => $session, 'session' => $session,
@@ -32,11 +39,12 @@ class QuickPrintController extends Controller {
// List/Manage Packages (CRUD) // List/Manage Packages (CRUD)
public function manage($session) { public function manage($session) {
$qpModel = new QuickPrintModel(); $qpModel = new QuickPrintModel();
$packages = $qpModel->getAllBySession($session);
// Need profiles for the Add/Edit Modal
$configModel = new Config(); $configModel = new Config();
$creds = $configModel->getSession($session); $creds = $configModel->getSession($session);
$routerId = $creds['id'] ?? null;
$packages = $routerId ? $qpModel->getAllByRouterId($routerId) : [];
$profiles = []; $profiles = [];
if ($creds) { if ($creds) {
$API = new RouterOSAPI(); $API = new RouterOSAPI();
@@ -63,7 +71,13 @@ class QuickPrintController extends Controller {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return; if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$session = $_POST['session'] ?? ''; $session = $_POST['session'] ?? '';
$configModel = new Config();
$creds = $configModel->getSession($session);
$routerId = $creds['id'] ?? 0;
$data = [ $data = [
'router_id' => $routerId,
'session_name' => $session, 'session_name' => $session,
'name' => $_POST['name'] ?? 'Package', 'name' => $_POST['name'] ?? 'Package',
'server' => $_POST['server'] ?? 'all', 'server' => $_POST['server'] ?? 'all',
@@ -71,6 +85,7 @@ class QuickPrintController extends Controller {
'prefix' => $_POST['prefix'] ?? '', 'prefix' => $_POST['prefix'] ?? '',
'char_length' => $_POST['char_length'] ?? 4, 'char_length' => $_POST['char_length'] ?? 4,
'price' => $_POST['price'] ?? 0, 'price' => $_POST['price'] ?? 0,
'selling_price' => $_POST['selling_price'] ?? ($_POST['price'] ?? 0),
'time_limit' => $_POST['time_limit'] ?? '', 'time_limit' => $_POST['time_limit'] ?? '',
'data_limit' => $_POST['data_limit'] ?? '', 'data_limit' => $_POST['data_limit'] ?? '',
'comment' => $_POST['comment'] ?? '', 'comment' => $_POST['comment'] ?? '',
@@ -85,6 +100,40 @@ class QuickPrintController extends Controller {
exit; exit;
} }
// CRUD: Update
public function update() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$session = $_POST['session'] ?? '';
$id = $_POST['id'] ?? '';
if (empty($id)) {
\App\Helpers\FlashHelper::set('error', 'common.error', 'toasts.error_missing_id', [], true);
header("Location: /" . $session . "/quick-print/manage");
exit;
}
$data = [
'name' => $_POST['name'] ?? 'Package',
'profile' => $_POST['profile'] ?? 'default',
'prefix' => $_POST['prefix'] ?? '',
'char_length' => $_POST['char_length'] ?? 4,
'price' => $_POST['price'] ?? 0,
'selling_price' => $_POST['selling_price'] ?? ($_POST['price'] ?? 0),
'time_limit' => $_POST['time_limit'] ?? '',
'data_limit' => $_POST['data_limit'] ?? '',
'comment' => $_POST['comment'] ?? '',
'color' => $_POST['color'] ?? 'bg-blue-500'
];
$qpModel = new QuickPrintModel();
$qpModel->update($id, $data); // Assuming update method exists in simple JSON model
\App\Helpers\FlashHelper::set('success', 'toasts.package_updated', 'toasts.package_updated_desc', [], true);
header("Location: /" . $session . "/quick-print/manage");
exit;
}
// CRUD: Delete // CRUD: Delete
public function delete() { public function delete() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return; if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
@@ -142,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) {
@@ -158,7 +207,9 @@ class QuickPrintController extends Controller {
$API->comm("/ip/hotspot/user/add", $userData); $API->comm("/ip/hotspot/user/add", $userData);
$API->disconnect(); $API->disconnect();
} else { } else {
die("Connection failed"); \App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/quick-print/manage'));
exit;
} }

View File

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

@@ -10,7 +10,7 @@ use App\Helpers\FormatHelper;
class SettingsController extends Controller { class SettingsController extends Controller {
public function __construct() { public function __construct() {
Middleware::auth(); // Auth handled by Router Middleware
} }
public function system() { public function system() {
@@ -33,10 +33,6 @@ class SettingsController extends Controller {
return $this->view('settings/index', ['routers' => $routers]); return $this->view('settings/index', ['routers' => $routers]);
} }
public function add() {
return $this->view('settings/form');
}
// ... (Existing Store methods) ... // ... (Existing Store methods) ...
public function store() { public function store() {
// Sanitize Session Name (Duplicate Frontend Logic) // Sanitize Session Name (Duplicate Frontend Logic)
@@ -81,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);
} }
@@ -102,33 +98,7 @@ class SettingsController extends Controller {
} }
public function edit() {
// ID passed via query param or route param?
// Our router supports {id} but let's check how we handle it.
// Router: /settings/edit/{id}
// In Router.php, params are passed to method.
// So method signature should be edit($id)
// Wait, Router.php passes matches as params array to invokeCallback.
// So we need to capture arguments here.
$args = func_get_args();
$id = $args[0] ?? null;
if (!$id) {
header('Location: /settings/routers');
exit;
}
$configModel = new Config();
$session = $configModel->getSessionById($id);
if (!$session) {
header('Location: /settings/routers');
exit;
}
return $this->view('settings/form', ['router' => $session]);
}
public function update() { public function update() {
$id = $_POST['id']; $id = $_POST['id'];
@@ -316,7 +286,7 @@ class SettingsController extends Controller {
// Restore Logos // Restore Logos
if (isset($json['logos'])) { if (isset($json['logos'])) {
$logoModel = new \App\Models\Logo(); $logoModel = new \App\Models\Logo();
$uploadDir = ROOT . '/public/assets/img/logos/'; $uploadDir = ROOT . '/public/uploads/logos/';
if (!file_exists($uploadDir)) { if (!file_exists($uploadDir)) {
mkdir($uploadDir, 0777, true); mkdir($uploadDir, 0777, true);
} }
@@ -341,7 +311,7 @@ class SettingsController extends Controller {
ON CONFLICT(id) DO UPDATE SET name=excluded.name, path=excluded.path, type=excluded.type, size=excluded.size", [ ON CONFLICT(id) DO UPDATE SET name=excluded.name, path=excluded.path, type=excluded.type, size=excluded.size", [
'id' => $logo['id'], 'id' => $logo['id'],
'name' => $logo['name'], 'name' => $logo['name'],
'path' => '/assets/img/logos/' . $filename, 'path' => '/uploads/logos/' . $filename,
'type' => $extension, 'type' => $extension,
'size' => $logo['size'] 'size' => $logo['size']
]); ]);
@@ -371,22 +341,24 @@ class SettingsController extends Controller {
} }
public function uploadLogo() { public function uploadLogo() {
if (!isset($_FILES['logo_file'])) { if (!isset($_FILES['logo_file']) || $_FILES['logo_file']['error'] !== UPLOAD_ERR_OK) {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
header('Location: /settings/logos'); header('Location: /settings/logos');
exit; exit;
} }
$logoModel = new \App\Models\Logo(); $logoModel = new \App\Models\Logo();
try { try {
$logoModel->add($_FILES['logo_file']); $result = $logoModel->add($_FILES['logo_file']);
if ($result) {
\App\Helpers\FlashHelper::set('success', 'toasts.logo_uploaded', 'toasts.logo_uploaded_desc', [], true);
} else {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Generic upload error', [], true);
}
} catch (\Exception $e) { } catch (\Exception $e) {
// Ideally flash error message to session \App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', $e->getMessage(), [], true);
// For now, redirect (logging error via debug or ignoring as per simple req)
// session_start() is implicit in Middleware usually or index
// $_SESSION['error'] = $e->getMessage();
} }
\App\Helpers\FlashHelper::set('success', 'toasts.logo_uploaded', 'toasts.logo_uploaded_desc', [], true);
header('Location: /settings/logos'); header('Location: /settings/logos');
} }
@@ -459,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

@@ -6,7 +6,7 @@ use App\Core\Controller;
use App\Models\VoucherTemplateModel; use App\Models\VoucherTemplateModel;
use App\Core\Middleware; use App\Core\Middleware;
class TemplateController extends Controller { class VoucherTemplateController extends Controller {
public function __construct() { public function __construct() {
Middleware::auth(); Middleware::auth();
@@ -19,7 +19,7 @@ class TemplateController extends Controller {
$data = [ $data = [
'templates' => $templates 'templates' => $templates
]; ];
return $this->view('settings/templates/index', $data); return $this->view('settings/voucher_templates/index', $data);
} }
public function preview($id) { public function preview($id) {
@@ -48,7 +48,7 @@ class TemplateController extends Controller {
$data = [ $data = [
'logoMap' => $logoMap 'logoMap' => $logoMap
]; ];
return $this->view('settings/templates/add', $data); // Note: add.php likely includes edit.php or is alias. View above says 'Template Editor (Shared)' return $this->view('settings/voucher_templates/add', $data);
} }
public function store() { public function store() {
@@ -62,6 +62,7 @@ class TemplateController extends Controller {
// I will use 'global' for templates created in Settings. // I will use 'global' for templates created in Settings.
$data = [ $data = [
'router_id' => 0, // Global templates
'session_name' => 'global', 'session_name' => 'global',
'name' => $name, 'name' => $name,
'content' => $content 'content' => $content
@@ -71,7 +72,7 @@ class TemplateController extends Controller {
$templateModel->add($data); $templateModel->add($data);
\App\Helpers\FlashHelper::set('success', 'toasts.template_created', 'toasts.template_created_desc', ['name' => $name], true); \App\Helpers\FlashHelper::set('success', 'toasts.template_created', 'toasts.template_created_desc', ['name' => $name], true);
header("Location: /settings/templates"); header("Location: /settings/voucher-templates");
exit; exit;
} }
@@ -80,7 +81,7 @@ class TemplateController extends Controller {
$template = $templateModel->getById($id); $template = $templateModel->getById($id);
if (!$template) { if (!$template) {
header("Location: /settings/templates"); header("Location: /settings/voucher-templates");
exit; exit;
} }
@@ -95,7 +96,7 @@ class TemplateController extends Controller {
'template' => $template, 'template' => $template,
'logoMap' => $logoMap 'logoMap' => $logoMap
]; ];
return $this->view('settings/templates/edit', $data); return $this->view('settings/voucher_templates/edit', $data);
} }
public function update() { public function update() {
@@ -114,7 +115,7 @@ class TemplateController extends Controller {
$templateModel->update($id, $data); $templateModel->update($id, $data);
\App\Helpers\FlashHelper::set('success', 'toasts.template_updated', 'toasts.template_updated_desc', ['name' => $name], true); \App\Helpers\FlashHelper::set('success', 'toasts.template_updated', 'toasts.template_updated_desc', ['name' => $name], true);
header("Location: /settings/templates"); header("Location: /settings/voucher-templates");
exit; exit;
} }
@@ -126,7 +127,7 @@ class TemplateController extends Controller {
$templateModel->delete($id); $templateModel->delete($id);
\App\Helpers\FlashHelper::set('success', 'toasts.template_deleted', 'toasts.template_deleted_desc', [], true); \App\Helpers\FlashHelper::set('success', 'toasts.template_deleted', 'toasts.template_deleted_desc', [], true);
header("Location: /settings/templates"); header("Location: /settings/voucher-templates");
exit; exit;
} }
} }

View File

@@ -45,7 +45,7 @@ class Console {
private function printBanner() { private function printBanner() {
echo "\n"; echo "\n";
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.0" . self::COLOR_RESET . "\n\n"; echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . \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

@@ -61,6 +61,7 @@ class Migrations {
// 6. Quick Prints (Voucher Printing Profiles) // 6. Quick Prints (Voucher Printing Profiles)
$pdo->exec("CREATE TABLE IF NOT EXISTS quick_prints ( $pdo->exec("CREATE TABLE IF NOT EXISTS quick_prints (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
router_id INTEGER,
session_name TEXT NOT NULL, session_name TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
server TEXT NOT NULL, server TEXT NOT NULL,
@@ -68,6 +69,7 @@ class Migrations {
prefix TEXT DEFAULT '', prefix TEXT DEFAULT '',
char_length INTEGER DEFAULT 4, char_length INTEGER DEFAULT 4,
price INTEGER DEFAULT 0, price INTEGER DEFAULT 0,
selling_price INTEGER DEFAULT 0,
time_limit TEXT DEFAULT '', time_limit TEXT DEFAULT '',
data_limit TEXT DEFAULT '', data_limit TEXT DEFAULT '',
comment TEXT DEFAULT '', comment TEXT DEFAULT '',
@@ -79,6 +81,7 @@ class Migrations {
// 7. Voucher Templates // 7. Voucher Templates
$pdo->exec("CREATE TABLE IF NOT EXISTS voucher_templates ( $pdo->exec("CREATE TABLE IF NOT EXISTS voucher_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
router_id INTEGER,
session_name TEXT NOT NULL, session_name TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,

View File

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

@@ -4,44 +4,156 @@ namespace App\Core;
class Router { class Router {
protected $routes = []; protected $routes = [];
protected $currentGroupMiddleware = [];
protected $lastRouteKey = null;
protected $middlewareAliases = [
'auth' => \App\Middleware\AuthMiddleware::class,
'cors' => \App\Middleware\CorsMiddleware::class,
'router.valid' => \App\Middleware\RouterCheckMiddleware::class,
];
/**
* Add a GET route
*/
public function get($path, $callback) { public function get($path, $callback) {
$this->routes['GET'][$path] = $callback; return $this->addRoute('GET', $path, $callback);
} }
/**
* Add a POST route
*/
public function post($path, $callback) { public function post($path, $callback) {
$this->routes['POST'][$path] = $callback; return $this->addRoute('POST', $path, $callback);
}
/**
* Add a OPTIONS route (Crucial for CORS Preflight)
*/
public function options($path, $callback) {
return $this->addRoute('OPTIONS', $path, $callback);
}
/**
* Add a PUT route
*/
public function put($path, $callback) {
return $this->addRoute('PUT', $path, $callback);
}
/**
* Add a PATCH route
*/
public function patch($path, $callback) {
return $this->addRoute('PATCH', $path, $callback);
}
/**
* Add a DELETE route
*/
public function delete($path, $callback) {
return $this->addRoute('DELETE', $path, $callback);
}
/**
* Add route to collection and return $this for chaining
*/
protected function addRoute($method, $path, $callback) {
$path = $this->normalizePath($path);
$this->routes[$method][$path] = [
'callback' => $callback,
'middleware' => $this->currentGroupMiddleware // Inherit group middleware
];
$this->lastRouteKey = ['method' => $method, 'path' => $path];
return $this;
}
/**
* Attach middleware to the last defined route
*/
public function middleware($names) {
if (!$this->lastRouteKey) return $this;
$method = $this->lastRouteKey['method'];
$path = $this->lastRouteKey['path'];
$middlewares = is_array($names) ? $names : [$names];
// Merge with existing middleware (from groups)
$this->routes[$method][$path]['middleware'] = array_merge(
$this->routes[$method][$path]['middleware'],
$middlewares
);
return $this;
}
/**
* Define a route group with shared attributes (middleware, prefix, etc.)
*/
public function group($attributes, callable $callback) {
$previousGroupMiddleware = $this->currentGroupMiddleware;
if (isset($attributes['middleware'])) {
$newMiddleware = is_array($attributes['middleware'])
? $attributes['middleware']
: [$attributes['middleware']];
$this->currentGroupMiddleware = array_merge(
$this->currentGroupMiddleware,
$newMiddleware
);
}
// Execute the callback with $this router instance
$callback($this);
// Restore previous state
$this->currentGroupMiddleware = $previousGroupMiddleware;
}
protected function normalizePath($path) {
return '/' . trim($path, '/');
} }
public function dispatch($uri, $method) { public function dispatch($uri, $method) {
// 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 = '/' . trim($path, '/'); $path = $this->normalizePath($path);
// Global Install Check: Redirect if database is missing // Global Install Check
$dbPath = ROOT . '/app/Database/database.sqlite'; $dbPath = ROOT . '/app/Database/database.sqlite';
if (!file_exists($dbPath)) { if (!file_exists($dbPath)) {
// Whitelist /install route and assets to prevent infinite loop
if ($path !== '/install' && strpos($path, '/assets/') !== 0) { if ($path !== '/install' && strpos($path, '/assets/') !== 0) {
header('Location: /install'); header('Location: /install');
exit; exit;
} }
} }
// Check exact match first // 1. Try Exact Match
if (isset($this->routes[$method][$path])) { if (isset($this->routes[$method][$path])) {
$callback = $this->routes[$method][$path]; return $this->runRoute($this->routes[$method][$path], []);
return $this->invokeCallback($callback);
} }
// Check dynamic routes // 2. Try Dynamic Routes (Regex)
foreach ($this->routes[$method] as $route => $callback) { foreach ($this->routes[$method] as $route => $config) {
// Convert route syntax to regex
// e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$# // e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$#
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route); $pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route);
$pattern = "#^" . $pattern . "$#"; $pattern = "#^" . $pattern . "$#";
@@ -49,13 +161,43 @@ class Router {
if (preg_match($pattern, $path, $matches)) { if (preg_match($pattern, $path, $matches)) {
array_shift($matches); // Remove full match array_shift($matches); // Remove full match
$matches = array_map('urldecode', $matches); $matches = array_map('urldecode', $matches);
return $this->invokeCallback($callback, $matches); return $this->runRoute($config, $matches);
} }
} }
\App\Helpers\ErrorHelper::show(404); \App\Helpers\ErrorHelper::show(404);
} }
protected function runRoute($routeConfig, $params) {
$callback = $routeConfig['callback'];
$middlewares = $routeConfig['middleware'];
// Pipeline Runner
$pipeline = array_reduce(
array_reverse($middlewares),
function ($nextStack, $middlewareName) {
return function ($request) use ($nextStack, $middlewareName) {
// Resolve Middleware Class
$class = $this->middlewareAliases[$middlewareName] ?? $middlewareName;
if (!class_exists($class)) {
throw new \Exception("Middleware class '$class' not found.");
}
$instance = new $class();
return $instance->handle($request, $nextStack);
};
},
function ($request) use ($callback, $params) {
// Final destination: The Controller
return $this->invokeCallback($callback, $params);
}
);
// Start the pipeline with the current request (mock object or just null/path)
return $pipeline($_SERVER);
}
protected function invokeCallback($callback, $params = []) { protected function invokeCallback($callback, $params = []) {
if (is_array($callback)) { if (is_array($callback)) {
$controller = new $callback[0](); $controller = new $callback[0]();

0
app/Database/.gitkeep Normal file
View File

View File

@@ -7,21 +7,26 @@ class ErrorHelper {
public static function show($code = 404, $message = 'Page Not Found', $description = null) { public static function show($code = 404, $message = 'Page Not Found', $description = null) {
http_response_code($code); http_response_code($code);
// Provide default descriptions for common codes // Provide default translation keys for common codes
if ($description === null) { if ($description === null) {
switch ($code) { switch ($code) {
case 403: case 403:
$description = "You do not have permission to access this resource."; $message = ($message === 'Page Not Found') ? 'errors.403_title' : $message; // Override default if simple
$description = "errors.403_desc";
break; break;
case 500: case 500:
$description = "Something went wrong on our end. Please try again later."; $message = ($message === 'Page Not Found') ? 'errors.500_title' : $message;
$description = "errors.500_desc";
break; break;
case 503: case 503:
$description = "Service Unavailable. The server is currently unable to handle the request due to maintenance or overload."; $message = ($message === 'Page Not Found') ? 'errors.503_title' : $message;
$description = "errors.503_desc";
break; break;
case 404: case 404:
default: default:
$description = "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable."; // If message is generic default, use key
if ($message === 'Page Not Found') $message = 'errors.404_title';
$description = "errors.404_desc";
break; break;
} }
} }

View File

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

@@ -0,0 +1,15 @@
<?php
namespace App\Middleware;
class AuthMiddleware implements MiddlewareInterface {
public function handle($request, \Closure $next) {
// Assume session is started in index.php
if (!isset($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
return $next($request);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Middleware;
use App\Core\Database;
class CorsMiddleware implements MiddlewareInterface {
public function handle($request, \Closure $next) {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Always allow if no origin (e.g. server-to-server or same-origin strict)
// Check generic logic: if valid origin, try to match DB
if (!empty($origin)) {
$db = Database::getInstance();
$stmt = $db->query("SELECT * FROM api_cors WHERE origin = ? OR origin = '*' LIMIT 1", [$origin]);
$rule = $stmt->fetch();
if ($rule) {
header("Access-Control-Allow-Origin: " . ($rule['origin'] === '*' ? '*' : $origin));
$methods = json_decode($rule['methods'], true) ?: ['GET', 'POST'];
header("Access-Control-Allow-Methods: " . implode(', ', $methods));
$headers = json_decode($rule['headers'], true) ?: ['*'];
header("Access-Control-Allow-Headers: " . implode(', ', $headers));
header("Access-Control-Max-Age: " . ($rule['max_age'] ?? 3600));
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
}
}
return $next($request);
}
}

View File

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

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Middleware;
use App\Models\Config;
class RouterCheckMiddleware implements MiddlewareInterface {
public function handle($request, \Closure $next) {
// We need to extract the session from the URI
// Pattern: /{session}/...
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$scriptName = dirname($_SERVER['SCRIPT_NAME']);
if (strpos($path, $scriptName) === 0) {
$path = substr($path, strlen($scriptName));
}
$path = '/' . trim($path, '/');
// Regex to grab first segment
if (preg_match('#^/([^/]+)#', $path, $matches)) {
$session = $matches[1];
// Exclude system routes that might mimic this pattern if any (like 'settings')
// But 'settings' is usually top level.
// If the user name their router "settings", it would conflict anyway.
// Let's assume standard routing structure.
if ($session === 'login' || $session === 'logout' || $session === 'settings' || $session === 'install' || $session === 'api') {
return $next($request);
}
$configModel = new Config();
if ($session !== 'demo' && !$configModel->getSession($session)) {
// Router NOT FOUND
\App\Helpers\ErrorHelper::show(404, 'errors.router_not_found_title', 'errors.router_not_found_desc');
}
}
return $next($request);
}
}

View File

@@ -20,6 +20,7 @@ class Config {
if ($router) { if ($router) {
return [ return [
'id' => $router['id'],
'ip' => $router['ip_address'], 'ip' => $router['ip_address'],
'ip_address' => $router['ip_address'], // Alias 'ip_address' => $router['ip_address'], // Alias
'user' => $router['username'], 'user' => $router['username'],

View File

@@ -74,7 +74,7 @@ class Logo {
$exists = $this->getById($id); $exists = $this->getById($id);
} while ($exists); } while ($exists);
$uploadDir = ROOT . '/public/assets/img/logos/'; $uploadDir = ROOT . '/public/uploads/logos/';
if (!file_exists($uploadDir)) { if (!file_exists($uploadDir)) {
mkdir($uploadDir, 0777, true); mkdir($uploadDir, 0777, true);
} }
@@ -86,7 +86,7 @@ class Logo {
$this->db->query("INSERT INTO {$this->table} (id, name, path, type, size) VALUES (:id, :name, :path, :type, :size)", [ $this->db->query("INSERT INTO {$this->table} (id, name, path, type, size) VALUES (:id, :name, :path, :type, :size)", [
'id' => $id, 'id' => $id,
'name' => $file['name'], 'name' => $file['name'],
'path' => '/assets/img/logos/' . $filename, 'path' => '/uploads/logos/' . $filename,
'type' => $extension, 'type' => $extension,
'size' => $file['size'] 'size' => $file['size']
]); ]);
@@ -98,17 +98,21 @@ class Logo {
public function syncFiles() { public function syncFiles() {
// One-time sync: scan folder, if file not in DB, add it. // One-time sync: scan folder, if file not in DB, add it.
$logoDir = ROOT . '/public/assets/img/logos/'; $logoDir = ROOT . '/public/uploads/logos/';
if (!file_exists($logoDir)) return; if (!file_exists($logoDir)) return;
$files = glob($logoDir . '*.{jpg,jpeg,png,gif,svg}', GLOB_BRACE); $files = [];
$extensions = ['jpg', 'jpeg', 'png', 'gif', 'svg'];
foreach ($extensions as $ext) {
$files = array_merge($files, glob($logoDir . '*.' . $ext));
}
foreach ($files as $file) { foreach ($files as $file) {
$filename = basename($file); $filename = basename($file);
$extension = pathinfo($filename, PATHINFO_EXTENSION); $extension = pathinfo($filename, PATHINFO_EXTENSION);
// Check if file is registered (maybe by path match) // Check if file is registered (maybe by path match)
$webPath = '/assets/img/logos/' . $filename; $webPath = '/uploads/logos/' . $filename;
$stmt = $this->db->query("SELECT COUNT(*) FROM {$this->table} WHERE path = :path", ['path' => $webPath]); $stmt = $this->db->query("SELECT COUNT(*) FROM {$this->table} WHERE path = :path", ['path' => $webPath]);
if ($stmt->fetchColumn() == 0) { if ($stmt->fetchColumn() == 0) {

View File

@@ -6,9 +6,9 @@ use App\Core\Database;
class QuickPrintModel { class QuickPrintModel {
public function getAllBySession($sessionName) { public function getAllByRouterId($routerId) {
$db = Database::getInstance(); $db = Database::getInstance();
$stmt = $db->query("SELECT * FROM quick_prints WHERE session_name = ?", [$sessionName]); $stmt = $db->query("SELECT * FROM quick_prints WHERE router_id = ?", [$routerId]);
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
@@ -20,17 +20,22 @@ class QuickPrintModel {
public function add($data) { public function add($data) {
$db = Database::getInstance(); $db = Database::getInstance();
$sql = "INSERT INTO quick_prints (session_name, name, server, profile, prefix, char_length, price, time_limit, data_limit, comment, color) // Insert router_id. session_name is kept for legacy/redundancy if needed, or we can drop it.
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; // Let's write both for now to be safe during transition, or user requirement "diubah saja" implies replacement using ID.
// But the table still has session_name column (we added router_id, didn't drop session_name).
$sql = "INSERT INTO quick_prints (router_id, session_name, name, server, profile, prefix, char_length, price, selling_price, time_limit, data_limit, comment, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
return $db->query($sql, [ return $db->query($sql, [
$data['session_name'], $data['router_id'],
$data['session_name'], // Keep filling it for now
$data['name'], $data['name'],
$data['server'], $data['server'] ?? 'all',
$data['profile'], $data['profile'],
$data['prefix'] ?? '', $data['prefix'] ?? '',
$data['char_length'] ?? 4, $data['char_length'] ?? 4,
$data['price'] ?? 0, $data['price'] ?? 0,
$data['selling_price'] ?? ($data['price'] ?? 0),
$data['time_limit'] ?? '', $data['time_limit'] ?? '',
$data['data_limit'] ?? '', $data['data_limit'] ?? '',
$data['comment'] ?? '', $data['comment'] ?? '',
@@ -40,15 +45,15 @@ class QuickPrintModel {
public function update($id, $data) { public function update($id, $data) {
$db = Database::getInstance(); $db = Database::getInstance();
$sql = "UPDATE quick_prints SET name=?, server=?, profile=?, prefix=?, char_length=?, price=?, time_limit=?, data_limit=?, comment=?, color=?, updated_at=CURRENT_TIMESTAMP WHERE id=?"; $sql = "UPDATE quick_prints SET name=?, profile=?, prefix=?, char_length=?, price=?, selling_price=?, time_limit=?, data_limit=?, comment=?, color=?, updated_at=CURRENT_TIMESTAMP WHERE id=?";
return $db->query($sql, [ return $db->query($sql, [
$data['name'], $data['name'],
$data['server'],
$data['profile'], $data['profile'],
$data['prefix'] ?? '', $data['prefix'] ?? '',
$data['char_length'] ?? 4, $data['char_length'] ?? 4,
$data['price'] ?? 0, $data['price'] ?? 0,
$data['selling_price'] ?? ($data['price'] ?? 0),
$data['time_limit'] ?? '', $data['time_limit'] ?? '',
$data['data_limit'] ?? '', $data['data_limit'] ?? '',
$data['comment'] ?? '', $data['comment'] ?? '',

View File

@@ -12,10 +12,9 @@ class VoucherTemplateModel {
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
public function getBySession($sessionName) { public function getAllByRouterId($routerId) {
// Templates can be global or session specific, but allow session filtering
$db = Database::getInstance(); $db = Database::getInstance();
$stmt = $db->query("SELECT * FROM voucher_templates WHERE session_name = ? OR session_name = 'global'", [$sessionName]); $stmt = $db->query("SELECT * FROM voucher_templates WHERE router_id = ?", [$routerId]);
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
@@ -27,8 +26,9 @@ class VoucherTemplateModel {
public function add($data) { public function add($data) {
$db = Database::getInstance(); $db = Database::getInstance();
$sql = "INSERT INTO voucher_templates (session_name, name, content) VALUES (?, ?, ?)"; $sql = "INSERT INTO voucher_templates (router_id, session_name, name, content) VALUES (?, ?, ?, ?)";
return $db->query($sql, [ return $db->query($sql, [
$data['router_id'],
$data['session_name'], $data['session_name'],
$data['name'], $data['name'],
$data['content'] $data['content']

View File

@@ -322,9 +322,34 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
// Init // Init
fetchInterfaces().then(() => { fetchInterfaces().then(() => {
// Start Polling after interfaces loaded // Start Polling after interfaces loaded
setInterval(fetchTraffic, 5000); // Every 5 seconds const reloadInterval = <?= ($reload_interval ?? 5) * 1000 ?>; // Convert sec to ms
setInterval(fetchTraffic, reloadInterval);
fetchTraffic(); fetchTraffic();
}); });
// 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

@@ -16,17 +16,21 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</div> </div>
<h1 class="text-6xl font-extrabold tracking-tighter mb-4 text-foreground"><?= $errorCode ?></h1> <h1 class="text-6xl font-extrabold tracking-tighter mb-4 text-foreground"><?= $errorCode ?></h1>
<h2 class="text-2xl font-bold mb-4 text-foreground"><?= $errorMessage ?></h2>
<p class="text-accents-5 max-w-md mx-auto mb-8"> <!-- Use data-i18n if message looks like a key (starts with errors.), otherwise show raw -->
<h2 class="text-2xl font-bold mb-4 text-foreground" <?= (strpos($errorMessage, 'errors.') === 0) ? 'data-i18n="'.$errorMessage.'"' : '' ?>>
<?= $errorMessage ?>
</h2>
<p class="text-accents-5 max-w-md mx-auto mb-8" <?= (strpos($errorDescription, 'errors.') === 0) ? 'data-i18n="'.$errorDescription.'"' : '' ?>>
<?= $errorDescription ?> <?= $errorDescription ?>
</p> </p>
<div class="flex flex-col sm:flex-row justify-center gap-4 w-full sm:w-auto"> <div class="flex flex-col sm:flex-row justify-center gap-4 w-full sm:w-auto">
<a href="/" class="btn btn-primary w-full sm:w-auto"> <a href="/" class="btn btn-primary w-full sm:w-auto" data-i18n="errors.return_home">
Return Home Return Home
</a> </a>
<button onclick="history.back()" class="btn btn-secondary w-full sm:w-auto"> <button onclick="history.back()" class="btn btn-secondary w-full sm:w-auto" data-i18n="errors.go_back">
Go Back Go Back
</button> </button>
</div> </div>

View File

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

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

View File

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

View File

@@ -22,9 +22,9 @@ sort($uniqueModes);
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary"> <a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span> <i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a> </a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/add" class="btn btn-primary"> <button onclick="openProfileModal('add')" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_profiles.add_profile">Add Profile</span> <i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_profiles.add_profile">Add Profile</span>
</a> </button>
</div> </div>
</div> </div>
@@ -79,8 +79,21 @@ sort($uniqueModes);
<tbody id="table-body"> <tbody id="table-body">
<?php if (!empty($profiles)): ?> <?php if (!empty($profiles)): ?>
<?php foreach ($profiles as $profile): ?> <?php foreach ($profiles as $profile): ?>
<tr class="table-row-item" <tr class="table-row-item group-row"
data-name="<?= strtolower($profile['name'] ?? '') ?>" data-id="<?= $profile['.id'] ?>"
data-name="<?= htmlspecialchars($profile['name'] ?? '') ?>"
data-shared-users="<?= htmlspecialchars($profile['shared-users'] ?? '1') ?>"
data-rate-limit="<?= htmlspecialchars($profile['rate-limit'] ?? '') ?>"
data-address-pool="<?= htmlspecialchars($profile['address-pool'] ?? 'none') ?>"
data-parent-queue="<?= htmlspecialchars($profile['parent-queue'] ?? 'none') ?>"
data-expired-mode="<?= htmlspecialchars($profile['meta']['expired_mode'] ?? 'none') ?>"
data-val-d="<?= htmlspecialchars($profile['val_d'] ?? '') ?>"
data-val-h="<?= htmlspecialchars($profile['val_h'] ?? '') ?>"
data-val-m="<?= htmlspecialchars($profile['val_m'] ?? '') ?>"
data-price="<?= htmlspecialchars($profile['meta']['price'] ?? '') ?>"
data-selling-price="<?= htmlspecialchars($profile['meta']['selling_price'] ?? '') ?>"
data-lock-user="<?= htmlspecialchars($profile['meta']['lock_user'] ?? 'Disable') ?>"
data-search-name="<?= strtolower($profile['name'] ?? '') ?>"
data-mode="<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>"> data-mode="<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>">
<td> <td>
@@ -89,9 +102,9 @@ sort($uniqueModes);
<i data-lucide="ticket" class="w-4 h-4"></i> <i data-lucide="ticket" class="w-4 h-4"></i>
</div> </div>
<div class="text-sm font-medium text-foreground"> <div class="text-sm font-medium text-foreground">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400"> <button onclick="openProfileModal('edit', this)" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400 text-left">
<?= htmlspecialchars($profile['name'] ?? '-') ?> <?= htmlspecialchars($profile['name'] ?? '-') ?>
</a> </button>
</div> </div>
</div> </div>
</td> </td>
@@ -129,9 +142,9 @@ sort($uniqueModes);
<td class="text-right text-sm font-medium"> <td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal"> <div class="flex items-center justify-end gap-2 table-actions-reveal">
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="btn bg-blue-50 hover:bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Edit"> <button onclick="openProfileModal('edit', this)" class="btn bg-blue-50 hover:bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i> <i data-lucide="edit-2" class="w-4 h-4"></i>
</a> </button>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/profile/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('hotspot_profiles.title') : 'Delete Profile?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete profile <?= $profile['name'] ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline"> <form action="/<?= htmlspecialchars($session) ?>/hotspot/profile/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('hotspot_profiles.title') : 'Delete Profile?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete profile <?= $profile['name'] ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>"> <input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $profile['.id'] ?>"> <input type="hidden" name="id" value="<?= $profile['.id'] ?>">
@@ -238,7 +251,7 @@ sort($uniqueModes);
update() { update() {
this.filteredRows = this.allRows.filter(row => { this.filteredRows = this.allRows.filter(row => {
const name = row.dataset.name || ''; const name = row.dataset.searchName || '';
const mode = row.dataset.mode || ''; const mode = row.dataset.mode || '';
if (this.filters.search && !name.includes(this.filters.search)) return false; if (this.filters.search && !name.includes(this.filters.search)) return false;
@@ -307,5 +320,201 @@ sort($uniqueModes);
const rows = document.querySelectorAll('.table-row-item'); const rows = document.querySelectorAll('.table-row-item');
new TableManager(rows, 10); new TableManager(rows, 10);
}); });
function openProfileModal(mode, btn = null) {
const template = document.getElementById('profile-form-template').innerHTML;
let title = window.i18n ? window.i18n.t('hotspot_profiles.form.add_title') : 'Add Profile';
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
if (mode === 'edit') {
title = window.i18n ? window.i18n.t('hotspot_profiles.form.edit_title') : 'Edit Profile';
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
}
const preConfirmFn = () => {
const form = Swal.getHtmlContainer().querySelector('form');
if(form.reportValidity()) {
form.submit();
return true;
}
return false;
};
const onOpenedFn = (popup) => {
const form = popup.querySelector('form');
// Validity Toggle Logic for Modal
const modeSelect = form.querySelector('#expired-mode');
const validityGroup = form.querySelector('#validity-group');
function toggleValidity() {
if (!modeSelect || !validityGroup) return;
if (modeSelect.value === 'none') {
validityGroup.classList.add('hidden');
} else {
validityGroup.classList.remove('hidden');
}
}
if (modeSelect) {
modeSelect.addEventListener('change', toggleValidity);
}
if (mode === 'edit' && btn) {
const row = btn.closest('tr');
form.action = "/<?= htmlspecialchars($session) ?>/hotspot/profile/update";
// Populate Hidden ID
const idInput = form.querySelector('#form-id');
idInput.disabled = false;
idInput.value = row.dataset.id;
// Populate Fields
form.querySelector('[name="name"]').value = row.dataset.name || '';
form.querySelector('[name="shared-users"]').value = row.dataset.sharedUsers || '1';
form.querySelector('[name="rate-limit"]').value = row.dataset.rateLimit || '';
// Selects
if(form.querySelector('[name="address-pool"]')) form.querySelector('[name="address-pool"]').value = row.dataset.addressPool;
if(form.querySelector('[name="parent-queue"]')) form.querySelector('[name="parent-queue"]').value = row.dataset.parentQueue;
if(form.querySelector('[name="expired_mode"]')) form.querySelector('[name="expired_mode"]').value = row.dataset.expiredMode;
if(form.querySelector('[name="lock_user"]')) form.querySelector('[name="lock_user"]').value = row.dataset.lockUser;
// Validity
form.querySelector('[name="validity_d"]').value = row.dataset.valD || '';
form.querySelector('[name="validity_h"]').value = row.dataset.valH || '';
form.querySelector('[name="validity_m"]').value = row.dataset.valM || '';
// Prices
form.querySelector('[name="price"]').value = row.dataset.price || '';
form.querySelector('[name="selling_price"]').value = row.dataset.sellingPrice || '';
// Initial Toggle Check
toggleValidity();
}
};
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
}
</script> </script>
<template id="profile-form-template">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 text-left">
<!-- Form Column -->
<div class="lg:col-span-2">
<form id="profile-form" action="/<?= htmlspecialchars($session) ?>/hotspot/profile/store" method="POST" class="space-y-4">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" id="form-id" disabled>
<!-- Name -->
<div class="space-y-1">
<label class="form-label" data-i18n="common.name">Name</label>
<input type="text" name="name" required class="w-full" data-i18n-placeholder="hotspot_profiles.form.name_placeholder" placeholder="e.g. 1Hour-Package">
</div>
<!-- Pools & Shared Users -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.address_pool">Address Pool</label>
<select name="address-pool" class="w-full">
<option value="none" data-i18n="common.forms.none">none</option>
<?php foreach ($pools as $pool): ?>
<?php if(isset($pool['name'])): ?>
<option value="<?= htmlspecialchars($pool['name']) ?>"><?= htmlspecialchars($pool['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.shared_users">Shared Users</label>
<input type="number" name="shared-users" value="1" min="1" class="w-full" placeholder="1">
</div>
</div>
<!-- Rate Limit & Parent Queue -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.rate_limit">Rate Limit (Rx/Tx)</label>
<input type="text" name="rate-limit" class="w-full" data-i18n-placeholder="hotspot_profiles.form.rate_limit_help" placeholder="e.g. 512k/1M">
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.parent_queue">Parent Queue</label>
<select name="parent-queue" class="w-full">
<option value="none" data-i18n="common.forms.none">none</option>
<?php foreach ($queues as $q): ?>
<option value="<?= htmlspecialchars($q) ?>"><?= htmlspecialchars($q) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- Expired Mode & Validity -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.expired_mode">Expired Mode</label>
<select name="expired_mode" id="expired-mode" class="w-full">
<option value="none" data-i18n="common.forms.none" selected>none</option>
<option value="rem">Remove</option>
<option value="ntf">Notice</option>
<option value="remc">Remove & Record</option>
<option value="ntfc">Notice & Record</option>
</select>
</div>
<div id="validity-group" class="hidden space-y-1 transition-all">
<label class="form-label" data-i18n="hotspot_profiles.form.validity">Validity</label>
<div class="flex w-full">
<input type="number" name="validity_d" min="0" class="w-full text-center rounded-r-none border-r-0" placeholder="0D">
<input type="number" name="validity_h" min="0" class="w-full text-center rounded-none border-r-0" placeholder="0H">
<input type="number" name="validity_m" min="0" class="w-full text-center rounded-l-none" placeholder="0M">
</div>
</div>
</div>
<!-- Prices -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.price">Price (Rp)</label>
<input type="number" name="price" class="w-full" placeholder="e.g. 5000">
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.selling_price">Selling Price (Rp)</label>
<input type="number" name="selling_price" class="w-full" placeholder="e.g. 7000">
</div>
</div>
<!-- Lock User -->
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_profiles.form.lock_user">Lock User</label>
<select name="lock_user" class="w-full">
<option value="Disable" data-i18n="common.forms.disabled">Disable</option>
<option value="Enable" data-i18n="common.forms.enabled">Enable</option>
</select>
</div>
<div class="h-12"></div> <!-- Spacer for selects -->
</form>
</div>
<!-- Tips Column -->
<div class="hidden lg:block space-y-4 border-l border-white/10 pl-6">
<h3 class="text-sm font-bold text-foreground flex items-center gap-2">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
<span data-i18n="hotspot_profiles.form.quick_tips">Quick Tips</span>
</h3>
<ul class="text-xs text-accents-5 space-y-3 list-disc pl-4">
<li data-i18n="hotspot_profiles.form.tip_rate_limit">
<strong>Rate Limit</strong>: Rx/Tx (Upload/Download). Example: <code>512k/1M</code>
</li>
<li data-i18n="hotspot_profiles.form.tip_expired_mode">
<strong>Expired Mode</strong>: Select 'Remove' or 'Notice' to enable Validity.
</li>
<li data-i18n="hotspot_profiles.form.tip_parent_queue">
<strong>Parent Queue</strong>: Assigns users to a specific parent queue for bandwidth management.
</li>
</ul>
</div>
</div>
</template>

View File

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

View File

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

View File

@@ -15,6 +15,10 @@ if (!empty($users)) {
} }
} }
sort($uniqueProfiles); sort($uniqueProfiles);
// $servers is passed from controller
if (!isset($servers)) $servers = [];
sort($uniqueComments); sort($uniqueComments);
?> ?>
@@ -27,9 +31,9 @@ sort($uniqueComments);
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary"> <a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span> <i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
</a> </a>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/add" class="btn btn-primary"> <button onclick="openUserModal('add')" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_users.add_user">Add User</span> <i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_users.add_user">Add User</span>
</a> </button>
</div> </div>
</div> </div>
@@ -107,13 +111,38 @@ sort($uniqueComments);
<tbody id="table-body"> <tbody id="table-body">
<?php if (!empty($users)): ?> <?php if (!empty($users)): ?>
<?php foreach ($users as $user): ?> <?php foreach ($users as $user): ?>
<?php
// Helper to split time limit for editing (Simple parsing or raw passing)
// Assuming time limit format from router is like 1d2h3m or just 1h
// We will pass the raw string if we can't easily split, OR rely on a JS parser.
// For now let's pass raw limit-uptime.
// Just prepare some safe values
$id = $user['.id'];
$name = $user['name'] ?? '';
$profile = $user['profile'] ?? 'default';
$comment = $user['comment'] ?? '';
$server = $user['server'] ?? 'all';
$password = $user['password'] ?? '';
// Limits
$limitUptime = $user['limit-uptime'] ?? '';
$limitBytes = $user['limit-bytes-total'] ?? '';
?>
<tr class="table-row-item" <tr class="table-row-item"
data-name="<?= strtolower($user['name'] ?? '') ?>" data-id="<?= htmlspecialchars($id) ?>"
data-profile="<?= $user['profile'] ?? 'default' ?>" data-name="<?= strtolower($name) ?>"
data-comment="<?= htmlspecialchars($user['comment'] ?? '') ?>"> data-rawname="<?= htmlspecialchars($name) ?>"
data-profile="<?= htmlspecialchars($profile) ?>"
data-comment="<?= htmlspecialchars($comment) ?>"
data-comment-raw="<?= htmlspecialchars($comment) ?>"
data-password="<?= htmlspecialchars($password) ?>"
data-server="<?= htmlspecialchars($server) ?>"
data-limit-uptime="<?= htmlspecialchars($limitUptime) ?>"
data-limit-bytes-total="<?= htmlspecialchars($limitBytes) ?>">
<td class="px-4 py-4"> <td class="px-4 py-4">
<input type="checkbox" name="selected_users[]" value="<?= htmlspecialchars($user['.id']) ?>" class="user-checkbox checkbox"> <input type="checkbox" name="selected_users[]" value="<?= htmlspecialchars($id) ?>" class="user-checkbox checkbox">
</td> </td>
<td> <td>
<div class="flex items-center w-full"> <div class="flex items-center w-full">
@@ -122,19 +151,19 @@ sort($uniqueComments);
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($user['name'] ?? '-') ?></div> <div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($name) ?></div>
<?php <?php
$status = \App\Helpers\HotspotHelper::getUserStatus($user); $status = \App\Helpers\HotspotHelper::getUserStatus($user);
echo \App\Helpers\ViewHelper::badge($status); echo \App\Helpers\ViewHelper::badge($status);
?> ?>
</div> </div>
<div class="text-xs text-accents-5"><?= htmlspecialchars($user['password'] ?? '******') ?></div> <div class="text-xs text-accents-5"><?= htmlspecialchars($password) ?></div>
</div> </div>
</div> </div>
</td> </td>
<td> <td>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<?= htmlspecialchars($user['profile'] ?? 'default') ?> <?= htmlspecialchars($profile) ?>
</span> </span>
</td> </td>
<td> <td>
@@ -148,19 +177,19 @@ sort($uniqueComments);
</div> </div>
</td> </td>
<td> <td>
<div class="text-sm text-accents-5 italic"><?= htmlspecialchars($user['comment'] ?? '-') ?></div> <div class="text-sm text-accents-5 italic"><?= htmlspecialchars($comment) ?></div>
</td> </td>
<td class="text-right text-sm font-medium"> <td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal"> <div class="flex items-center justify-end gap-2 table-actions-reveal">
<button onclick="printUser('<?= htmlspecialchars($user['.id']) ?>')" class="btn-icon" title="Print"> <button onclick="printUser('<?= htmlspecialchars($id) ?>')" class="btn-icon" title="Print">
<i data-lucide="printer" class="w-4 h-4"></i> <i data-lucide="printer" class="w-4 h-4"></i>
</button> </button>
<a href="/<?= htmlspecialchars($session) ?>/hotspot/user/edit/<?= urlencode($user['.id']) ?>" class="btn-icon inline-flex items-center justify-center" title="Edit"> <button onclick="openUserModal('edit', this)" class="btn-icon inline-flex items-center justify-center" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i> <i data-lucide="edit-2" class="w-4 h-4"></i>
</a> </button>
<form action="/<?= htmlspecialchars($session) ?>/hotspot/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Delete User?', 'Are you sure you want to delete user <?= htmlspecialchars($user['name'] ?? '') ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline"> <form action="/<?= htmlspecialchars($session) ?>/hotspot/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Delete User?', 'Are you sure you want to delete user <?= htmlspecialchars($name) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>"> <input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" value="<?= $user['.id'] ?>"> <input type="hidden" name="id" value="<?= $id ?>">
<button type="submit" class="btn-icon-danger" title="Delete"> <button type="submit" class="btn-icon-danger" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
@@ -188,6 +217,133 @@ sort($uniqueComments);
</div> </div>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?> <?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
<!-- Add/Edit User Template -->
<template id="user-form-template">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 text-left">
<!-- Form Column -->
<div class="lg:col-span-2">
<form id="user-form" action="/<?= htmlspecialchars($session) ?>/hotspot/store" method="POST" class="space-y-4">
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<input type="hidden" name="id" id="form-id" disabled> <!-- Disabled for Add -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Name & Password -->
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_users.form.username">Username</label>
<div class="input-group">
<span class="input-icon"><i data-lucide="user" class="w-4 h-4"></i></span>
<input type="text" name="name" required class="pl-10 w-full" data-i18n-placeholder="hotspot_users.form.username_placeholder" placeholder="e.g. voucher123">
</div>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_users.form.password">Password</label>
<div class="input-group">
<span class="input-icon"><i data-lucide="key" class="w-4 h-4"></i></span>
<input type="text" name="password" required class="pl-10 w-full" data-i18n-placeholder="hotspot_users.form.password_placeholder" placeholder="e.g. 123456">
</div>
</div>
<!-- Profile -->
<div class="space-y-1 col-span-1 md:col-span-2">
<label class="form-label" data-i18n="hotspot_users.form.profile">Profile</label>
<select name="profile" class="w-full" data-search="true">
<?php foreach($uniqueProfiles as $p): ?>
<option value="<?= htmlspecialchars($p) ?>"><?= htmlspecialchars($p) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Server -->
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_users.form.server">Server</label>
<select name="server" class="w-full">
<option value="all">all</option>
<?php
if (!empty($servers)):
foreach($servers as $s):
$sName = $s['name'] ?? '';
if ($sName === 'all' || empty($sName)) continue;
?>
<option value="<?= htmlspecialchars($sName) ?>"><?= htmlspecialchars($sName) ?></option>
<?php
endforeach;
endif;
?>
</select>
</div>
<!-- Comment -->
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_users.form.comment">Comment</label>
<div class="input-group">
<span class="input-icon"><i data-lucide="message-square" class="w-4 h-4"></i></span>
<input type="text" name="comment" class="pl-10 w-full" placeholder="Optional note">
</div>
</div>
<!-- Time Limit -->
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_users.form.time_limit">Time Limit</label>
<div class="flex w-full">
<div class="relative flex-1">
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">D</span>
<input type="number" name="timelimit_d" min="0" class="w-full pr-6 rounded-r-none border-r-0 text-center" placeholder="0">
</div>
<div class="relative flex-1">
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">H</span>
<input type="number" name="timelimit_h" min="0" max="23" class="w-full pr-6 rounded-none border-r-0 text-center" placeholder="0">
</div>
<div class="relative flex-1">
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">M</span>
<input type="number" name="timelimit_m" min="0" max="59" class="w-full pr-6 rounded-l-none text-center" placeholder="0">
</div>
</div>
</div>
<!-- Data Limit -->
<div class="space-y-1">
<label class="form-label" data-i18n="hotspot_users.form.data_limit">Data Limit</label>
<div class="flex relative w-full">
<div class="relative flex-grow z-0">
<span class="input-icon"><i data-lucide="database" class="w-4 h-4"></i></span>
<input type="number" name="datalimit_val" min="0" class="form-input w-full pl-10 rounded-r-none" placeholder="0">
</div>
<div class="relative -ml-px w-20 z-0">
<select name="datalimit_unit" class="w-full rounded-l-none bg-accents-1 text-center font-medium">
<option value="MB" selected>MB</option>
<option value="GB">GB</option>
</select>
</div>
</div>
</div>
</div>
<!-- Spacer for dropdowns -->
<div class="h-24"></div>
</form>
</div>
<!-- Tips Column -->
<div class="hidden lg:block space-y-4 border-l border-white/10 pl-6">
<h3 class="text-sm font-bold text-foreground flex items-center gap-2">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-400"></i>
<span data-i18n="hotspot_users.form.quick_tips">Quick Tips</span>
</h3>
<ul class="text-xs text-accents-5 space-y-3 list-disc pl-4">
<li data-i18n="hotspot_users.form.tip_profiles">
<strong>Profiles</strong> define the default speed limits, session timeout, and shared users policy.
</li>
<li data-i18n="hotspot_users.form.tip_time_limit">
<strong>Time Limit</strong> is the total accumulated uptime allowed for this user.
</li>
<li data-i18n="hotspot_users.form.tip_data_limit">
<strong>Data Limit</strong> will override the profile's data limit settings if specified here. Set to 0 to use profile default.
</li>
</ul>
</div>
</div>
</template>
<script> <script>
class TableManager { class TableManager {
constructor(rows, itemsPerPage = 10) { constructor(rows, itemsPerPage = 10) {
@@ -244,9 +400,7 @@ sort($uniqueComments);
} }
}); });
// Custom Select Listener (Mutation Observer or custom event if we emitted one, // Filters
// but for now relying on underlying SELECT change or custom-select class behavior)
// Since CustomSelect updates the original Select, we listen to change on original select
document.getElementById('filter-profile').addEventListener('change', (e) => { document.getElementById('filter-profile').addEventListener('change', (e) => {
this.filters.profile = e.target.value; this.filters.profile = e.target.value;
this.currentPage = 1; this.currentPage = 1;
@@ -258,11 +412,8 @@ sort($uniqueComments);
this.currentPage = 1; this.currentPage = 1;
this.update(); this.update();
}); });
// Re-bind actions when external CustomSelect updates the select value
// CustomSelect triggers 'change' event on original select, so standard listener works!
// Listen for language change to update pagination text // Listen for language change
window.addEventListener('languageChanged', () => { window.addEventListener('languageChanged', () => {
this.render(); this.render();
}); });
@@ -272,10 +423,10 @@ sort($uniqueComments);
// Apply Filters // Apply Filters
this.filteredRows = this.allRows.filter(row => { this.filteredRows = this.allRows.filter(row => {
const name = row.dataset.name || ''; const name = row.dataset.name || '';
const comment = (row.dataset.comment || '').toLowerCase(); // dataset comment value const comment = (row.dataset.comment || '').toLowerCase();
const profile = row.dataset.profile || ''; const profile = row.dataset.profile || '';
// 1. Search (Name or Comment) // 1. Search
if (this.filters.search) { if (this.filters.search) {
const matchName = name.includes(this.filters.search); const matchName = name.includes(this.filters.search);
const matchComment = comment.includes(this.filters.search); const matchComment = comment.includes(this.filters.search);
@@ -285,7 +436,7 @@ sort($uniqueComments);
// 2. Profile // 2. Profile
if (this.filters.profile && profile !== this.filters.profile) return false; if (this.filters.profile && profile !== this.filters.profile) return false;
// 3. Comment (Exact match for dropdown) // 3. Comment
if (this.filters.comment && row.dataset.comment !== this.filters.comment) return false; if (this.filters.comment && row.dataset.comment !== this.filters.comment) return false;
return true; return true;
@@ -303,7 +454,7 @@ sort($uniqueComments);
const start = (this.currentPage - 1) * this.itemsPerPage; const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total); const end = Math.min(start + this.itemsPerPage, total);
// Update Text (Use Translation) // Update Text
if (window.i18n) { if (window.i18n) {
const text = window.i18n.t('common.table.showing', { const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1, start: total === 0 ? 0 : start + 1,
@@ -312,9 +463,9 @@ sort($uniqueComments);
}); });
document.getElementById('pagination-text').textContent = text; document.getElementById('pagination-text').textContent = text;
} else { } else {
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1; // Fallback
this.elements.endIdx.textContent = end; const el = document.getElementById('pagination-text');
this.elements.totalCount.textContent = total; el.innerHTML = `Showing <span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span> to <span class="font-medium text-foreground">${end}</span> of <span class="font-medium text-foreground">${total}</span> users`;
} }
// Clear & Append Rows // Clear & Append Rows
@@ -332,118 +483,177 @@ sort($uniqueComments);
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`; this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
} }
// Re-init Icons for new rows // Re-init Icons
if (typeof lucide !== 'undefined') { if (typeof lucide !== 'undefined') lucide.createIcons();
lucide.createIcons();
}
// Update Checkbox Logic (Select All should act on visible?) // Reset "Select All"
// We usually reset "Select All" check when page changes
document.getElementById('select-all').checked = false; document.getElementById('select-all').checked = false;
} }
} }
document.addEventListener('DOMContentLoaded', () => { // --- Modal Logic ---
// Init Custom Selects function openUserModal(mode, btn = null) {
if (typeof CustomSelect !== 'undefined') { const template = document.getElementById('user-form-template').innerHTML;
document.querySelectorAll('.custom-select').forEach(select => {
new CustomSelect(select);
});
}
let title = window.i18n ? window.i18n.t('hotspot_users.add_user') : 'Add User';
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
if (mode === 'edit') {
title = window.i18n ? window.i18n.t('hotspot_users.edit_user') : 'Edit User';
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
}
const preConfirmFn = () => {
const form = Swal.getHtmlContainer().querySelector('form');
if(form.reportValidity()) {
form.submit();
return true;
}
return false;
};
const onOpenedFn = (popup) => {
const form = popup.querySelector('form');
if (mode === 'edit' && btn) {
const row = btn.closest('tr');
form.action = "/<?= htmlspecialchars($session) ?>/hotspot/update";
// Populate Hidden ID
const idInput = form.querySelector('#form-id');
idInput.disabled = false;
idInput.value = row.dataset.id; // Ensure data-id is set on TR!
// Populate Fields (Assuming data attributes or simple values)
// NOTE: For full data (limits, etc), we might need to fetch OR put all in data attributes
// Let's rely on data attributes for speed, but need to add them to TR first
form.querySelector('[name="name"]').value = row.dataset.rawname || '';
form.querySelector('[name="password"]').value = row.dataset.password || '';
form.querySelector('[name="comment"]').value = row.dataset.commentRaw || '';
// Selects
const profileSel = form.querySelector('[name="profile"]');
if(profileSel) profileSel.value = row.dataset.profile;
const serverSel = form.querySelector('[name="server"]');
if(serverSel) serverSel.value = row.dataset.server || 'all';
// Limits (Parsing from data attributes)
// Time Limit
const tLimit = row.dataset.limitUptime || '';
// Simple regex parsing for 1d2h3m (Mikrotik format)
// This is complex to parse perfectly from string back to split fields without a helper
// For now, let's just leave 0 or try best effort if available
// Ideally, we pass split values in data attributes from PHP
if (row.dataset.timeD) form.querySelector('[name="timelimit_d"]').value = row.dataset.timeD;
if (row.dataset.timeH) form.querySelector('[name="timelimit_h"]').value = row.dataset.timeH;
if (row.dataset.timeM) form.querySelector('[name="timelimit_m"]').value = row.dataset.timeM;
// Data Limit
if (row.dataset.limitBytesTotal) {
const bytes = parseInt(row.dataset.limitBytesTotal);
if (bytes > 0) {
if (bytes >= 1073741824) { // GB
form.querySelector('[name="datalimit_val"]').value = (bytes / 1073741824).toFixed(0); // integer prefer
form.querySelector('[name="datalimit_unit"]').value = 'GB';
} else { // MB
form.querySelector('[name="datalimit_val"]').value = (bytes / 1048576).toFixed(0);
form.querySelector('[name="datalimit_unit"]').value = 'MB';
}
}
}
}
};
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
}
document.addEventListener('DOMContentLoaded', () => {
// Init Checkboxes & Table methods
const selectAll = document.getElementById('select-all');
const toolbar = document.getElementById('batch-toolbar');
const countSpan = document.getElementById('selected-count');
const tableBody = document.getElementById('table-body');
// Init Custom Selects on Filter Bar
if (typeof CustomSelect !== 'undefined') {
document.querySelectorAll('.custom-select.form-filter').forEach(s => new CustomSelect(s));
}
// Init Table // Init Table
const rows = document.querySelectorAll('.table-row-item'); const rows = document.querySelectorAll('.table-row-item');
const manager = new TableManager(rows, 10); const manager = new TableManager(rows, 10);
// --- Toolbar Logic (Copied/Adapted) --- // Toolbar Logic
const selectAll = document.getElementById('select-all');
const toolbar = document.getElementById('batch-toolbar');
const countSpan = document.getElementById('selected-count');
const tableBody = document.getElementById('table-body'); // Dynamic body
function updateToolbar() { function updateToolbar() {
const checked = document.querySelectorAll('.user-checkbox:checked'); const checked = document.querySelectorAll('.user-checkbox:checked');
countSpan.textContent = checked.length; countSpan.textContent = checked.length;
if (checked.length > 0) toolbar.classList.remove('translate-y-20', 'opacity-0');
if (checked.length > 0) { else toolbar.classList.add('translate-y-20', 'opacity-0');
toolbar.classList.remove('translate-y-20', 'opacity-0');
} else {
toolbar.classList.add('translate-y-20', 'opacity-0');
}
} }
selectAll.addEventListener('change', (e) => { if(selectAll) {
const isChecked = e.target.checked; selectAll.addEventListener('change', (e) => {
// Only select visible rows on current page const isChecked = e.target.checked;
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox'); // Only select visible rows
visibleCheckboxes.forEach(cb => cb.checked = isChecked); const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
updateToolbar(); visibleCheckboxes.forEach(cb => cb.checked = isChecked);
});
// Event Delegation for dynamic rows
tableBody.addEventListener('change', (e) => {
if (e.target.classList.contains('user-checkbox')) {
updateToolbar(); updateToolbar();
if (!e.target.checked) selectAll.checked = false; });
} }
});
if(tableBody) {
tableBody.addEventListener('change', (e) => {
if (e.target.classList.contains('user-checkbox')) {
updateToolbar();
if (!e.target.checked) selectAll.checked = false;
}
});
}
}); });
// Actions // Actions
function printUser(id) { function printUser(id) {
const width = 400; const width = 400; const height = 600;
const height = 600;
const left = (window.innerWidth - width) / 2; const left = (window.innerWidth - width) / 2;
const top = (window.innerHeight - height) / 2; const top = (window.innerHeight - height) / 2;
const session = '<?= htmlspecialchars($session) ?>'; const session = '<?= htmlspecialchars($session) ?>';
const url = `/${session}/hotspot/print/${encodeURIComponent(id)}`; window.open(`/${session}/hotspot/print/${encodeURIComponent(id)}`, `PrintUser`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
window.open(url, `PrintUser`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
} }
function printSelected() { function printSelected() {
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value); const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return alert(window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "No users selected."); if (selected.length === 0) return Mivo.alert('info', 'No selection', window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "No users selected.");
const width = 800; const width = 800; const height = 600;
const height = 600;
const left = (window.innerWidth - width) / 2; const left = (window.innerWidth - width) / 2;
const top = (window.innerHeight - height) / 2; const top = (window.innerHeight - height) / 2;
const session = '<?= htmlspecialchars($session) ?>'; const session = '<?= htmlspecialchars($session) ?>';
const ids = selected.map(id => encodeURIComponent(id)).join(','); const ids = selected.map(id => encodeURIComponent(id)).join(',');
const url = `/${session}/hotspot/print-batch?ids=${ids}`; window.open(`/${session}/hotspot/print-batch?ids=${ids}`, `PrintBatch`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
window.open(url, `PrintBatch`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
} }
function deleteSelected() { function deleteSelected() {
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value); const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return alert(window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "Please select at least one user."); if (selected.length === 0) return Mivo.alert('info', 'No selection', window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "Please select at least one user.");
const title = window.i18n ? window.i18n.t('common.delete') : 'Delete Users?'; const title = window.i18n ? window.i18n.t('common.delete') : 'Delete Users?';
const msg = window.i18n ? window.i18n.t('common.confirm_delete') : `Are you sure you want to delete ${selected.length} users?`; const msg = window.i18n ? window.i18n.t('common.confirm_delete') : `Are you sure you want to delete ${selected.length} users?`;
Mivo.confirm(title, msg, window.i18n.t('common.delete'), window.i18n.t('common.cancel')).then(res => { Mivo.confirm(title, msg, window.i18n.t('common.delete'), window.i18n.t('common.cancel')).then(res => {
if (!res) return; if (!res) return;
// Create a form to submit
const form = document.createElement('form'); const form = document.createElement('form');
form.method = 'POST'; form.method = 'POST';
form.action = '/<?= htmlspecialchars($session) ?>/hotspot/delete'; // Re-uses the delete endpoint form.action = '/<?= htmlspecialchars($session) ?>/hotspot/delete';
const sInput = document.createElement('input');
const sessionInput = document.createElement('input'); sInput.type = 'hidden'; sInput.name = 'session'; sInput.value = '<?= htmlspecialchars($session) ?>';
sessionInput.type = 'hidden'; form.appendChild(sInput);
sessionInput.name = 'session';
sessionInput.value = '<?= htmlspecialchars($session) ?>';
form.appendChild(sessionInput);
const idInput = document.createElement('input'); const idInput = document.createElement('input');
idInput.type = 'hidden'; idInput.type = 'hidden'; idInput.name = 'id'; idInput.value = selected.join(',');
idInput.name = 'id';
idInput.value = selected.join(','); // Comma separated IDs
form.appendChild(idInput); form.appendChild(idInput);
document.body.appendChild(form); document.body.appendChild(form);
form.submit(); form.submit();
document.body.removeChild(form);
}); });
} }
</script> </script>

View File

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

View File

@@ -6,13 +6,34 @@
<?php else: ?> <?php else: ?>
</div> <!-- /.container (Navbar Global) --> </div> <!-- /.container (Navbar Global) -->
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200"> <footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200 py-8 text-center space-y-4">
<div class="max-w-7xl mx-auto px-4 py-6 text-center text-sm text-accents-5"> <!-- Links Row -->
<p><?= \App\Config\SiteConfig::getFooter() ?></p> <div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
<a href="https://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>
<span>Docs</span>
</a>
<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>
<span>Community</span>
</a>
<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>
<span>Repo</span>
</a>
</div>
<!-- Copyright Row -->
<div class="text-xs text-accents-4 opacity-50">
<?= \App\Config\SiteConfig::getFooter() ?>
</div> </div>
</footer> </footer>
<?php endif; ?> <?php endif; ?>
<script>
window.MIVO_VERSION = "<?= \App\Config\SiteConfig::APP_VERSION ?>";
</script>
<script src="/assets/js/modules/update-checker.js"></script>
<script> <script>
// Global Theme Toggle Logic (Class-based for multiple instances) // Global Theme Toggle Logic (Class-based for multiple instances)
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -129,20 +150,67 @@
</script> </script>
<script> <script>
// Global Dropdown & Sidebar Logic // Global Dropdown & Sidebar Logic
let menuTimeout;
function toggleMenu(menuId, button) { function toggleMenu(menuId, button) {
if (menuTimeout) clearTimeout(menuTimeout);
const menu = document.getElementById(menuId); const menu = document.getElementById(menuId);
if (!menu) return; if (!menu) return;
// Handle Dropdowns (IDs start with 'lang-' or 'session-') // Handle Dropdowns (IDs start with 'lang-', 'session-', or is 'notification-')
if (menuId.startsWith('lang-') || menuId === 'session-dropdown') { if (menuId.startsWith('lang-') || menuId === 'session-dropdown' || menuId === 'notification-dropdown') {
if (menu.classList.contains('invisible')) { const sidebarHeader = document.getElementById('sidebar-header');
const isOpening = menu.classList.contains('invisible');
if (isOpening) {
// Smart Positioning Logic
// 1. Reset to base state (remove specific overrides to measure natural/preferred state)
// But we want to preserve 'absolute' etc. The HTML has 'left-1/2 -translate-x-1/2' by default for sidebar.
// We'll calculate based on button rect and assumed menu width (w-48 = 12rem = 192px approx, or measure)
const btnRect = button.getBoundingClientRect();
const menuWidth = 192; // Approx w-48 standard. Better to measure if possible, but invisible elements have width.
// Actually, if we make it visible but opacity-0 first, we can measure.
// But simpler math:
const centerX = btnRect.left + (btnRect.width / 2);
const leftEdge = centerX - (menuWidth / 2);
const rightEdge = centerX + (menuWidth / 2);
// Remove conflicting positioning classes first to ensure a clean slate if we need to override
menu.classList.remove('left-0', 'right-0', 'left-1/2', '-translate-x-1/2', 'origin-top-left', 'origin-top-right', 'origin-top', 'left-3');
// Decision Tree
if (leftEdge < 10) {
// overflow left -> Align Left
menu.classList.add('left-0', 'origin-top-left');
} else if (rightEdge > window.innerWidth - 10) {
// overflow right -> Align Right
menu.classList.add('right-0', 'origin-top-right');
} else {
// Safe to Center
menu.classList.add('left-1/2', '-translate-x-1/2', 'origin-top');
}
// Open // Open
menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none'); menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
menu.classList.add('opacity-100', 'scale-100', 'visible', 'pointer-events-auto'); menu.classList.add('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
// Special Case: Sidebar Lang Dropdown needs overflow visible on header
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
sidebarHeader.classList.remove('overflow-hidden');
sidebarHeader.classList.add('overflow-visible');
}
} else { } else {
// Close // Close
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none'); menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto'); menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
// Revert Overflow
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
sidebarHeader.classList.add('overflow-hidden');
sidebarHeader.classList.remove('overflow-visible');
}
} }
return; return;
} }
@@ -175,20 +243,22 @@
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const dropdowns = document.querySelectorAll('[id^="lang-dropdown"], #session-dropdown'); const dropdowns = document.querySelectorAll('[id^="lang-dropdown"], #session-dropdown, #notification-dropdown');
dropdowns.forEach(dropdown => { dropdowns.forEach(dropdown => {
const sidebarHeader = document.getElementById('sidebar-header');
if (!dropdown.classList.contains('invisible')) { if (!dropdown.classList.contains('invisible')) {
// Find the trigger button (previous sibling usually)
// Robust way: check if click is inside dropdown OR inside the button that toggles it
// Since button calls toggleMenu, we just need to ignore clicks inside dropdown and button?
// Actually, simpler: just check if click is OUTSIDE dropdown.
// But if click is on button, let button handler toggle it (don't double toggle).
const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`); const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`);
if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) { if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) {
dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none'); dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto'); dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
// Revert Sidebar Overflow if needed
if (dropdown.id === 'lang-dropdown-sidebar' && sidebarHeader) {
sidebarHeader.classList.add('overflow-hidden');
sidebarHeader.classList.remove('overflow-visible');
}
} }
} }
}); });
@@ -209,18 +279,37 @@
if (data.success) { if (data.success) {
Mivo.toast('success', title.replace('?', ''), 'The command has been sent to the router.'); Mivo.toast('success', title.replace('?', ''), 'The command has been sent to the router.');
} else { } else {
Swal.fire({ Mivo.alert('error', 'Action Failed', data.error || 'Unknown error occurred.');
icon: 'error',
title: 'Action Failed',
text: data.error || 'Unknown error occurred.',
background: 'rgba(255, 255, 255, 0.8)',
backdrop: 'rgba(0,0,0,0.1)'
});
} }
} catch (err) { } catch (err) {
Mivo.toast('error', 'Connection Error', 'Failed to reach the server.'); Mivo.toast('error', 'Connection Error', 'Failed to reach the server.');
} }
} }
// Auto-Close Helper with Debounce
function closeMenu(menuId) {
if (menuTimeout) clearTimeout(menuTimeout);
// Notification dropdown is more "sticky" (800ms vs 300ms elsewhere)
const delay = (menuId === 'notification-dropdown') ? 800 : 300;
menuTimeout = setTimeout(() => {
const menu = document.getElementById(menuId);
const sidebarHeader = document.getElementById('sidebar-header');
if (menu && !menu.classList.contains('invisible')) {
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
// Revert Overflow if needed
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
sidebarHeader.classList.add('overflow-hidden');
sidebarHeader.classList.remove('overflow-visible');
}
}
}, 300); // 300ms delay to prevent accidental closure
}
</script> </script>
<?php \App\Core\Hooks::doAction('mivo_footer'); ?>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,23 @@
<footer class="mt-auto py-6 text-center text-xs text-accents-5 opacity-60"> <footer class="mt-auto py-8 text-center space-y-4">
<?= \App\Config\SiteConfig::getFooter() ?> <div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
<a href="https://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>
<span>Docs</span>
</a>
<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>
<span>Community</span>
</a>
<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>
<span>Repo</span>
</a>
</div>
<!-- Copyright Row -->
<div class="text-xs text-accents-4 opacity-50">
<?= \App\Config\SiteConfig::getFooter() ?>
</div>
</footer> </footer>
<script> <script>
@@ -38,34 +56,17 @@
// Use Custom Toasts for most notifications (Success, Info, Error) // Use Custom Toasts for most notifications (Success, Info, Error)
// Only use Modal (Swal) for specific heavy warnings or questions if needed // Only use Modal (Swal) for specific heavy warnings or questions if needed
// Use Toasts for standard notifications
if (['success', 'info', 'error', 'warning'].includes(type)) { if (['success', 'info', 'error', 'warning'].includes(type)) {
// Assuming Mivo.toast is available globally or via another script check
if (window.Mivo && window.Mivo.toast) { if (window.Mivo && window.Mivo.toast) {
Mivo.toast(type, title, message); Mivo.toast(type, title, message);
} else {
console.log('Toast:', title, message);
} }
} else { } else {
// Use Swal for 'question' or fallback // For questions or other types, use Modal Alert
if (typeof Swal !== 'undefined') { if (window.Mivo && window.Mivo.alert) {
Swal.fire({ Mivo.alert(type || 'info', title, message);
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`, } else if (typeof Swal !== 'undefined') {
title: title, Swal.fire(title, message, type);
text: message,
confirmButtonText: 'OK',
customClass: {
popup: 'swal2-premium-card',
confirmButton: 'btn btn-primary',
cancelButton: 'btn btn-secondary',
},
buttonsStyling: false,
heightAuto: false,
didOpen: () => {
lucide.createIcons();
}
});
} else {
alert(`${title}\n${message}`);
} }
} }
}; };
@@ -78,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>
@@ -54,12 +54,16 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
</script> </script>
<script src="/assets/js/jquery.min.js"></script> <script src="/assets/js/jquery.min.js"></script>
<script src="/assets/js/lucide.min.js"></script> <script src="/assets/js/lucide.min.js"></script>
<script src="/assets/js/custom-select.js" defer></script> <script>
<script src="/assets/js/datatable.js" defer></script> window.currentVersion = '<?= \App\Config\SiteConfig::APP_VERSION ?>';
</script>
<script src="/assets/js/mivo.js" defer></script>
<script src="/assets/js/modules/updater.js" defer></script>
<script src="/assets/js/components/select.js" defer></script>
<script src="/assets/js/components/datatable.js" defer></script>
<script src="/assets/js/sweetalert2.all.min.js" defer></script> <script src="/assets/js/sweetalert2.all.min.js" defer></script>
<script src="/assets/js/alert-helper.js" defer></script> <script src="/assets/js/modules/alert.js" defer></script>
<script src="/assets/js/i18n.js" defer></script> <script src="/assets/js/modules/i18n.js" defer></script>
<script src="/assets/js/i18n.js" defer></script>
<style> <style>
/* Global Form Input Style - Matches Vercel Design System */ /* Global Form Input Style - Matches Vercel Design System */
@@ -110,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

@@ -8,8 +8,9 @@
<link rel="stylesheet" href="/assets/css/styles.css"> <link rel="stylesheet" href="/assets/css/styles.css">
<script src="/assets/js/lucide.min.js"></script> <script src="/assets/js/lucide.min.js"></script>
<script src="/assets/js/sweetalert2.all.min.js" defer></script> <script src="/assets/js/sweetalert2.all.min.js" defer></script>
<script src="/assets/js/alert-helper.js" defer></script> <script src="/assets/js/mivo.js" defer></script>
<script src="/assets/js/i18n.js" defer></script> <script src="/assets/js/modules/alert.js" defer></script>
<script src="/assets/js/modules/i18n.js" defer></script>
<style> <style>
/* Custom Keyframes */ /* Custom Keyframes */
@keyframes fade-in-up { @keyframes fade-in-up {
@@ -28,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">
@@ -39,30 +41,126 @@
<div class="absolute top-[30%] -right-[15%] w-[60vw] h-[60vw] rounded-full bg-purple-500/20 dark:bg-purple-500/5 blur-[100px] animate-pulse" style="animation-duration: 6s; animation-delay: 1s;"></div> <div class="absolute top-[30%] -right-[15%] w-[60vw] h-[60vw] rounded-full bg-purple-500/20 dark:bg-purple-500/5 blur-[100px] animate-pulse" style="animation-duration: 6s; animation-delay: 1s;"></div>
</div> </div>
<!-- Floating Theme Toggle (Bottom Right) --> <!-- Top Right Controls (Pill Theme Toggle & Lang Switcher) -->
<button id="theme-toggle" class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-background border border-accents-2 shadow-lg text-accents-5 hover:text-foreground hover:border-foreground transition-all duration-300 group" style="position: fixed; bottom: 1.5rem; right: 1.5rem;"> <div class="fixed top-4 right-4 z-50 flex items-center space-x-3">
<i data-lucide="moon" class="w-5 h-5 block dark:hidden group-hover:scale-110 transition-transform"></i> <!-- Language Switcher -->
<i data-lucide="sun" class="w-5 h-5 hidden dark:block group-hover:scale-110 transition-transform"></i> <div class="relative group">
</button> <button onclick="toggleMenu('lang-dropdown-public', this)" class="h-9 px-3 rounded-full bg-background/50 backdrop-blur-md border border-accents-2 hover:border-foreground/20 text-accents-5 hover:text-foreground transition-all flex items-center shadow-sm">
<i data-lucide="globe" class="w-4 h-4 mr-2"></i>
<span class="text-xs font-semibold uppercase tracking-wider" id="current-lang-label">EN</span>
<i data-lucide="chevron-down" class="w-3 h-3 ml-2 opacity-50"></i>
</button>
<!-- Dropdown -->
<div id="lang-dropdown-public" class="hidden absolute right-0 mt-2 w-32 bg-background/95 backdrop-blur-2xl border border-white/10 rounded-xl shadow-2xl py-1 z-50 transform origin-top-right transition-all duration-200" onmouseleave="closeMenu('lang-dropdown-public')">
<button onclick="changeLanguage('en')" class="w-full text-left px-4 py-2 text-xs font-medium text-accents-5 hover:text-foreground hover:bg-white/5 flex items-center group">
<span class="mr-2 text-lg">🇺🇸</span> English
</button>
<button onclick="changeLanguage('id')" class="w-full text-left px-4 py-2 text-xs font-medium text-accents-5 hover:text-foreground hover:bg-white/5 flex items-center group">
<span class="mr-2 text-lg">🇮🇩</span> Indonesia
</button>
</div>
</div>
<!-- Theme Toggle Pill -->
<div class="h-9 p-1 bg-accents-2/50 backdrop-blur-md border border-accents-2 rounded-full flex items-center relative" id="theme-pill">
<!-- Gliding Background -->
<div class="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-background rounded-full shadow-sm transition-all duration-300 ease-spring" id="theme-glider" style="left: 4px;"></div>
<button onclick="setTheme('light')" class="relative z-10 w-8 h-full flex items-center justify-center text-accents-5 hover:text-foreground transition-colors rounded-full" id="btn-light">
<i data-lucide="sun" class="w-4 h-4"></i>
</button>
<button onclick="setTheme('dark')" class="relative z-10 w-8 h-full flex items-center justify-center text-accents-5 hover:text-foreground transition-colors rounded-full" id="btn-dark">
<i data-lucide="moon" class="w-4 h-4"></i>
</button>
</div>
</div>
<script> <script>
// Toggle Menu Helper (Reuse or define for public if main footer not loaded)
// Public footer includes site config footer, but maybe not main JS.
// Let's define simple toggle for public page to be safe and independent.
function toggleMenu(id, btn) {
const el = document.getElementById(id);
if (!el) return;
const isHidden = el.classList.contains('hidden');
// Close others if needed (optional)
if (isHidden) {
el.classList.remove('hidden', 'scale-95', 'opacity-0');
el.classList.add('scale-100', 'opacity-100');
} else {
closeMenu(id);
}
}
function closeMenu(id) {
const el = document.getElementById(id);
if (el && !el.classList.contains('hidden')) {
el.classList.remove('scale-100', 'opacity-100');
el.classList.add('hidden', 'scale-95', 'opacity-0');
}
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons(); lucide.createIcons();
// Theme Toggle Logic // Theme Logic
const themeToggleBtn = document.getElementById('theme-toggle'); const glider = document.getElementById('theme-glider');
const btnLight = document.getElementById('btn-light');
const btnDark = document.getElementById('btn-dark');
const htmlElement = document.documentElement; const htmlElement = document.documentElement;
if(themeToggleBtn){ window.setTheme = (theme) => {
themeToggleBtn.addEventListener('click', () => { if (theme === 'dark') {
if (htmlElement.classList.contains('dark')) { htmlElement.classList.add('dark');
htmlElement.classList.remove('dark'); localStorage.theme = 'dark';
localStorage.theme = 'light'; glider.style.transform = 'translateX(100%)';
} else { // adjustment: logic depends on width.
htmlElement.classList.add('dark'); // container is w-8+w-8+padding.
localStorage.theme = 'dark'; // simplest is just left/right toggle classes or transform.
} // using transform translateX(100%) works if width is exactly 50% parent minus padding.
}); // padding is 1 (4px). buttons are w-8 (32px).
// let's use explicit left style or class-based positioning if easier.
// Tailwind 'translate-x-full' moves 100% of own width.
// If glider is w-[calc(50%-4px)], moving 100% of itself is almost correct but includes gap.
// Let's rely on simple pixel math or percentage relative to parent?
// actually `left: 4px` vs `left: calc(100% - width - 4px)`.
glider.style.left = 'auto';
glider.style.right = '4px';
} else {
htmlElement.classList.remove('dark');
localStorage.theme = 'light';
glider.style.right = 'auto';
glider.style.left = '4px';
}
// Update Active Colors
if (theme === 'dark') {
btnDark.classList.add('text-foreground');
btnLight.classList.remove('text-foreground');
} else {
btnLight.classList.add('text-foreground');
btnDark.classList.remove('text-foreground');
}
};
// Init
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
setTheme('dark');
} else {
setTheme('light');
} }
// Language Init (Mock)
const currentLang = localStorage.getItem('mivo_lang') || 'en';
const langLabel = document.getElementById('current-lang-label');
if(langLabel) langLabel.innerText = currentLang.toUpperCase();
window.changeLanguage = (lang) => {
localStorage.setItem('mivo_lang', lang);
// Reload or use i18n module to reload
location.reload();
};
}); });
</script> </script>

View File

@@ -15,28 +15,47 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
</a> </a>
<!-- Desktop Navigation Links (Hidden on Mobile) --> <!-- Desktop Navigation Links (Hidden on Mobile) -->
<?php if(isset($_SESSION['user_id'])): ?>
<div class="hidden md:flex items-center gap-6 text-sm font-medium"> <div class="hidden md:flex items-center gap-6 text-sm font-medium">
<a href="/" class="relative py-1 <?= ($uri == '/' || $uri == '/home') ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Home</a> <a href="/" class="relative py-1 <?= ($uri == '/' || $uri == '/home') ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Home</a>
<a href="/settings" class="relative py-1 <?= (strpos($uri, '/settings') === 0) ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Settings</a> <a href="/settings" class="relative py-1 <?= (strpos($uri, '/settings') === 0) ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Settings</a>
</div> </div>
<?php endif; ?>
</div> </div>
<!-- Right side controls --> <!-- Right side controls -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Desktop Control Pill (Hidden on Mobile) --> <!-- Desktop Control Pill (Hidden on Mobile) -->
<div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform"> <div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform">
<!-- Language Switcher --> <!-- Notification Bell -->
<div class="relative group"> <div class="relative group" onmouseleave="closeMenu('notification-dropdown')">
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-nav', this)" title="Change Language"> <button id="notification-bell" type="button" class="pill-lang-btn relative" onclick="toggleMenu('notification-dropdown', this)" title="Notifications">
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i> <i data-lucide="bell" class="w-4 h-4"></i>
<span id="update-badge" class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full hidden animate-pulse"></span>
</button> </button>
<div id="lang-dropdown-nav" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50"> <div id="notification-dropdown" class="absolute right-0 top-full mt-3 w-64 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge">
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="notifications.title">Notifications</div>
<div id="notification-content" class="p-4 text-sm text-accents-5 text-center" data-i18n="notifications.empty">
No new notifications
</div>
</div>
</div>
<div class="pill-divider"></div>
<!-- Language Switcher -->
<div class="relative group" onmouseleave="closeMenu('lang-dropdown-desktop')">
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-desktop', this)" title="Change Language">
<i data-lucide="languages" class="w-4 h-4"></i>
</button>
<div id="lang-dropdown-desktop" class="absolute right-0 top-full mt-3 w-48 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge">
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div> <div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
<?php <?php
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages(); $languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
foreach ($languages as $lang): foreach ($languages as $lang):
$pathArg = isset($lang['path']) ? "', '" . $lang['path'] : "";
?> ?>
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang"> <button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?><?= $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>
@@ -44,8 +63,6 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
</div> </div>
</div> </div>
<div class="pill-divider"></div>
<!-- Theme Toggle (Segmented) --> <!-- Theme Toggle (Segmented) -->
<div class="segmented-switch theme-toggle" title="Toggle Theme"> <div class="segmented-switch theme-toggle" title="Toggle Theme">
<div class="segmented-switch-slider"></div> <div class="segmented-switch-slider"></div>
@@ -88,6 +105,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
<div id="mobile-navbar-menu" class="md:hidden border-t border-accents-2 bg-background/95 backdrop-blur-xl transition-all duration-300 ease-in-out max-h-0 opacity-0 invisible overflow-hidden"> <div id="mobile-navbar-menu" class="md:hidden border-t border-accents-2 bg-background/95 backdrop-blur-xl transition-all duration-300 ease-in-out max-h-0 opacity-0 invisible overflow-hidden">
<div class="px-4 pt-4 pb-6 space-y-4"> <div class="px-4 pt-4 pb-6 space-y-4">
<!-- Nav Links --> <!-- Nav Links -->
<?php if(isset($_SESSION['user_id'])): ?>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= ($uri == '/' || $uri == '/home') ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>"> <a href="/" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= ($uri == '/' || $uri == '/home') ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>">
<i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i> <i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
@@ -98,6 +116,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
<span>Settings</span> <span>Settings</span>
</a> </a>
</div> </div>
<?php endif; ?>
<!-- Mobile Controls Overlay --> <!-- Mobile Controls Overlay -->
<div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4"> <div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4">
@@ -105,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

@@ -3,7 +3,7 @@
$uri = $_SERVER['REQUEST_URI'] ?? '/'; $uri = $_SERVER['REQUEST_URI'] ?? '/';
$isDashboard = strpos($uri, '/dashboard') !== false; $isDashboard = strpos($uri, '/dashboard') !== false;
$isGenerate = strpos($uri, '/hotspot/generate') !== false; $isGenerate = strpos($uri, '/hotspot/generate') !== false;
$isTemplates = strpos($uri, '/settings/templates') !== false; $isTemplates = strpos($uri, '/settings/voucher-templates') !== false;
$isSettings = ($uri === '/settings' || strpos($uri, '/settings/') !== false) && !$isTemplates; $isSettings = ($uri === '/settings' || strpos($uri, '/settings/') !== false) && !$isTemplates;
// Hotspot Group Active Check // Hotspot Group Active Check
@@ -106,7 +106,7 @@ $getInitials = function($name) {
<aside id="sidebar" class="w-64 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-[40px] fixed md:static inset-y-0 left-0 z-40 transform -translate-x-full md:translate-x-0 transition-transform duration-200 flex flex-col h-full"> <aside id="sidebar" class="w-64 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-[40px] fixed md:static inset-y-0 left-0 z-40 transform -translate-x-full md:translate-x-0 transition-transform duration-200 flex flex-col h-full">
<!-- Sidebar Header --> <!-- Sidebar Header -->
<!-- Sidebar Header --> <!-- Sidebar Header -->
<div class="group flex flex-col items-center py-5 border-b border-accents-2 flex-shrink-0 relative cursor-default overflow-hidden"> <div id="sidebar-header" class="group flex flex-col items-center py-5 border-b border-accents-2 flex-shrink-0 relative cursor-default overflow-hidden">
<div class="relative w-full h-10 flex items-center justify-center"> <div class="relative w-full h-10 flex items-center justify-center">
<!-- Brand (Slides out to the Left) --> <!-- Brand (Slides out to the Left) -->
<div class="flex items-center gap-2 font-bold text-2xl tracking-tighter transition-all duration-500 ease-in-out group-hover:-translate-x-full group-hover:opacity-0"> <div class="flex items-center gap-2 font-bold text-2xl tracking-tighter transition-all duration-500 ease-in-out group-hover:-translate-x-full group-hover:opacity-0">
@@ -119,17 +119,19 @@ $getInitials = function($name) {
<div class="absolute inset-0 hidden md:flex items-center justify-center transition-all duration-500 ease-in-out translate-x-full opacity-0 group-hover:translate-x-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto z-10"> <div class="absolute inset-0 hidden md:flex items-center justify-center transition-all duration-500 ease-in-out translate-x-full opacity-0 group-hover:translate-x-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto z-10">
<div class="control-pill scale-90 transition-transform hover:scale-100 shadow-lg bg-white/10 dark:bg-black/20 backdrop-blur-md"> <div class="control-pill scale-90 transition-transform hover:scale-100 shadow-lg bg-white/10 dark:bg-black/20 backdrop-blur-md">
<!-- Language Switcher --> <!-- Language Switcher -->
<div class="relative group/lang"> <!-- Language Switcher (Mivo Component) -->
<!-- Language Switcher -->
<div class="relative group/lang" onmouseleave="closeMenu('lang-dropdown-sidebar')">
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-sidebar', this)" title="Change Language"> <button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-sidebar', this)" title="Change Language">
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i> <i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
</button> </button>
<div id="lang-dropdown-sidebar" class="absolute left-1/2 -translate-x-1/2 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none z-50"> <div id="lang-dropdown-sidebar" class="absolute left-1/2 -translate-x-1/2 top-full mt-3 w-48 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge" onmouseenter="if(typeof menuTimeout !== 'undefined') clearTimeout(menuTimeout)">
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div> <div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
<?php <?php
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages(); $languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
foreach ($languages as $lang): foreach ($languages as $lang):
?> ?>
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang-item"> <button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang-item">
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang-item:scale-110"></span> <span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang-item:scale-110"></span>
<span><?= $lang['name'] ?></span> <span><?= $lang['name'] ?></span>
</button> </button>
@@ -163,7 +165,7 @@ $getInitials = function($name) {
<div class="flex-1 overflow-y-auto" style="direction: rtl;"> <div class="flex-1 overflow-y-auto" style="direction: rtl;">
<div class="py-4 px-3 space-y-1" style="direction: ltr;"> <div class="py-4 px-3 space-y-1" style="direction: ltr;">
<!-- Session Switcher --> <!-- Session Switcher -->
<div class="px-3 mb-6 relative"> <div class="px-3 mb-6 relative" onmouseleave="closeMenu('session-dropdown')">
<button type="button" class="w-full group grid grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-2.5 rounded-xl bg-white/50 dark:bg-white/5 border border-accents-2 dark:border-white/10 hover:bg-white/80 dark:hover:bg-white/10 transition-all decoration-0 overflow-hidden shadow-sm" onclick="toggleMenu('session-dropdown', this)"> <button type="button" class="w-full group grid grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-2.5 rounded-xl bg-white/50 dark:bg-white/5 border border-accents-2 dark:border-white/10 hover:bg-white/80 dark:hover:bg-white/10 transition-all decoration-0 overflow-hidden shadow-sm" onclick="toggleMenu('session-dropdown', this)">
<!-- Initials --> <!-- Initials -->
<div class="h-8 w-8 rounded-lg bg-accents-2/50 group-hover:bg-accents-2 flex items-center justify-center text-xs font-bold text-accents-6 group-hover:text-foreground transition-colors flex-shrink-0"> <div class="h-8 w-8 rounded-lg bg-accents-2/50 group-hover:bg-accents-2 flex items-center justify-center text-xs font-bold text-accents-6 group-hover:text-foreground transition-colors flex-shrink-0">
@@ -185,7 +187,7 @@ $getInitials = function($name) {
</button> </button>
<!-- Dropdown --> <!-- Dropdown -->
<div id="session-dropdown" class="absolute top-full left-3 w-[calc(100%-1.5rem)] z-50 mt-1 bg-background border border-accents-2 rounded-lg shadow-lg overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none"> <div id="session-dropdown" class="absolute top-full left-3 w-[calc(100%-1.5rem)] z-50 mt-1 bg-background border border-accents-2 rounded-lg shadow-lg overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none dropdown-bridge" onmouseenter="if(typeof menuTimeout !== 'undefined') clearTimeout(menuTimeout)">
<div class="py-1 max-h-60 overflow-y-auto"> <div class="py-1 max-h-60 overflow-y-auto">
<div class="px-3 py-2 text-xs font-semibold text-accents-5 uppercase tracking-wider bg-accents-1/50 border-b border-accents-2" data-i18n="sidebar.switch_session"> <div class="px-3 py-2 text-xs font-semibold text-accents-5 uppercase tracking-wider bg-accents-1/50 border-b border-accents-2" data-i18n="sidebar.switch_session">
Switch Session Switch Session
@@ -377,11 +379,37 @@ $getInitials = function($name) {
</a> </a>
<!-- Voucher Templates --> <!-- Voucher Templates -->
<a href="/settings/templates" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= $isTemplates ? 'bg-white/40 dark:bg-white/5 shadow-sm text-foreground ring-1 ring-white/10' : 'text-accents-6 hover:text-foreground hover:bg-white/5' ?>"> <a href="/settings/voucher-templates" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= $isTemplates ? 'bg-white/40 dark:bg-white/5 shadow-sm text-foreground ring-1 ring-white/10' : 'text-accents-6 hover:text-foreground hover:bg-white/5' ?>">
<i data-lucide="file-code" class="w-4 h-4"></i> <i data-lucide="file-code" class="w-4 h-4"></i>
<span data-i18n="sidebar.templates">Templates</span> <span data-i18n="sidebar.templates">Templates</span>
</a> </a>
<!-- Support Separator -->
<div class="pt-4 pb-1 px-3">
<div class="text-xs font-semibold text-accents-5 uppercase tracking-wider" data-i18n="sidebar.support">Support</div>
</div>
<!-- Docs -->
<a href="https://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>
<span data-i18n="sidebar.docs">Documentation</span>
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
</a>
<!-- Community -->
<a href="https://github.com/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>
<span data-i18n="sidebar.community">Community</span>
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
</a>
<!-- Repo -->
<a href="https://github.com/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>
<span data-i18n="sidebar.repo">Repository</span>
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
</a>
</div> </div>
<!-- Sidebar Footer --> <!-- Sidebar Footer -->
@@ -435,7 +463,7 @@ $getInitials = function($name) {
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-mobile', this)" title="Change Language"> <button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-mobile', this)" title="Change Language">
<i data-lucide="languages" class="w-4 h-4"></i> <i data-lucide="languages" class="w-4 h-4"></i>
</button> </button>
<div id="lang-dropdown-mobile" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50"> <div id="lang-dropdown-mobile" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge" onmouseenter="if(typeof menuTimeout !== 'undefined') clearTimeout(menuTimeout)">
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div> <div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
<?php <?php
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages(); $languages = \App\Helpers\LanguageHelper::getAvailableLanguages();

View File

@@ -11,9 +11,10 @@ function isActive($path, $current) {
$menu = [ $menu = [
['label' => 'routers_title', 'url' => '/settings', 'namespace' => 'settings'], ['label' => 'routers_title', 'url' => '/settings', 'namespace' => 'settings'],
['label' => 'system', 'url' => '/settings/system', 'namespace' => 'settings'], ['label' => 'system', 'url' => '/settings/system', 'namespace' => 'settings'],
['label' => 'templates_title', 'url' => '/settings/templates', 'namespace' => 'settings'], ['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'],
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'], ['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'], ['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
['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

@@ -35,7 +35,7 @@
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
<i data-lucide="ticket" class="h-4 w-4 text-accents-5"></i> <i data-lucide="ticket" class="h-4 w-4 text-accents-5"></i>
</div> </div>
<input type="text" id="voucher-code" class="form-input pl-10 h-11 text-lg font-mono tracking-wide" placeholder="Ex: QWASZX" required autofocus autocomplete="off"> <input type="text" id="voucher-code" class="form-input pl-10 h-11 text-lg font-mono tracking-wide" placeholder="Ex: QWASZX" data-i18n="status.code_placeholder" required autofocus autocomplete="off">
</div> </div>
</div> </div>
@@ -55,13 +55,6 @@
<!-- Logic Script --> <!-- Logic Script -->
<script> <script>
// Initialize Input placeholder
document.addEventListener('DOMContentLoaded', () => {
const inp = document.getElementById('voucher-code');
if(inp && window.i18n) {
inp.placeholder = window.i18n.t('status.code_placeholder');
}
});
async function checkStatus(e) { async function checkStatus(e) {
e.preventDefault(); e.preventDefault();
@@ -110,7 +103,7 @@
<!-- Header --> <!-- Header -->
<div class="relative p-5 md:p-6 border-b border-white/10 flex justify-between items-center bg-white/10 dark:bg-black/20"> <div class="relative p-5 md:p-6 border-b border-white/10 flex justify-between items-center bg-white/10 dark:bg-black/20">
<div> <div>
<span class="text-[10px] text-accents-5 font-bold uppercase tracking-widest block mb-0.5">Voucher Code</span> <span class="text-[10px] text-accents-5 font-bold uppercase tracking-widest block mb-0.5">${window.i18n.t('status.code')}</span>
<span class="font-mono text-xl md:text-2xl font-black tracking-tighter text-foreground">${d.username}</span> <span class="font-mono text-xl md:text-2xl font-black tracking-tighter text-foreground">${d.username}</span>
</div> </div>
<div class="px-3 py-1.5 rounded-full text-[10px] font-black uppercase tracking-[0.15em] border ${statusColor} shadow-sm backdrop-blur-sm bg-opacity-80"> <div class="px-3 py-1.5 rounded-full text-[10px] font-black uppercase tracking-[0.15em] border ${statusColor} shadow-sm backdrop-blur-sm bg-opacity-80">
@@ -121,7 +114,7 @@
<!-- Data Usage Bar --> <!-- Data Usage Bar -->
<div class="relative p-5 md:p-6 pb-2"> <div class="relative p-5 md:p-6 pb-2">
<div class="flex justify-between items-end mb-2"> <div class="flex justify-between items-end mb-2">
<span class="text-xs font-bold text-accents-5 uppercase tracking-wide">Data Remaining</span> <span class="text-xs font-bold text-accents-5 uppercase tracking-wide">${window.i18n.t('status.data_remaining')}</span>
<span class="text-lg font-black text-blue-600 dark:text-blue-400 font-mono tracking-tight">${d.data_left}</span> <span class="text-lg font-black text-blue-600 dark:text-blue-400 font-mono tracking-tight">${d.data_left}</span>
</div> </div>
<div class="w-full h-2.5 bg-accents-2 rounded-full overflow-hidden shadow-inner ring-1 ring-black/5 dark:ring-white/5"> <div class="w-full h-2.5 bg-accents-2 rounded-full overflow-hidden shadow-inner ring-1 ring-black/5 dark:ring-white/5">
@@ -130,7 +123,7 @@
</div> </div>
</div> </div>
<div class="text-right mt-1.5"> <div class="text-right mt-1.5">
<span class="text-[10px] font-semibold text-accents-4 uppercase tracking-wider">Used: <span class="text-foreground">${d.data_used}</span></span> <span class="text-[10px] font-semibold text-accents-4 uppercase tracking-wider">${window.i18n.t('status.used')}: <span class="text-foreground">${d.data_used}</span></span>
</div> </div>
</div> </div>
@@ -139,19 +132,19 @@
<table class="w-full text-sm text-left"> <table class="w-full text-sm text-left">
<tbody class="divide-y divide-white/10"> <tbody class="divide-y divide-white/10">
<tr> <tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Package</td> <td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.package')}</td>
<td class="py-3 text-right font-bold text-foreground font-mono">${d.profile}</td> <td class="py-3 text-right font-bold text-foreground font-mono">${d.profile}</td>
</tr> </tr>
<tr> <tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Validity</td> <td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.validity')}</td>
<td class="py-3 text-right font-bold text-foreground font-mono">${d.validity}</td> <td class="py-3 text-right font-bold text-foreground font-mono">${d.validity}</td>
</tr> </tr>
<tr> <tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Uptime</td> <td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.uptime')}</td>
<td class="py-3 text-right font-medium text-foreground font-mono">${d.uptime_used}</td> <td class="py-3 text-right font-medium text-foreground font-mono">${d.uptime_used}</td>
</tr> </tr>
<tr> <tr>
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Expires</td> <td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">${window.i18n.t('status.expires')}</td>
<td class="py-3 text-right font-medium text-foreground font-mono">${d.expiration}</td> <td class="py-3 text-right font-medium text-foreground font-mono">${d.expiration}</td>
</tr> </tr>
</tbody> </tbody>
@@ -160,44 +153,32 @@
</div> </div>
`; `;
Swal.fire({ Mivo.alert('success', window.i18n.t('status.details_title'), htmlContent, {
title: 'Voucher Details', customClass: { popup: 'w-full max-w-md' } // Override width only, others merged
html: htmlContent,
icon: 'success', // Using success icon for positive result
confirmButtonText: 'OK',
customClass: {
popup: 'swal2-premium-card w-full max-w-md', // Ensure good width
confirmButton: 'btn btn-primary w-full',
},
buttonsStyling: false
}); });
} else { } else {
Swal.fire({ Mivo.alert('error',
icon: 'error', window.i18n.t('status.not_found_title'),
title: 'Voucher Not Found', json.message && json.message !== 'Voucher Not Found' ? json.message : window.i18n.t('status.not_found_desc'),
text: json.message || "The voucher code you entered does not exist.", {
confirmButtonText: 'Try Again', confirmButtonText: window.i18n.t('status.try_again'),
customClass: { didClose: () => {
popup: 'swal2-premium-card', setTimeout(() => {
confirmButton: 'btn btn-primary', const el = document.getElementById('voucher-code');
}, if(el) { el.focus(); el.select(); }
buttonsStyling: false, }, 100);
didClose: () => { }
setTimeout(() => {
const el = document.getElementById('voucher-code');
if(el) { el.focus(); el.select(); }
}, 100);
} }
}); );
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
Swal.fire({ Swal.fire({
icon: 'error', icon: 'error',
title: 'System Error', title: window.i18n.t('errors.500_title'),
text: 'Unable to connect to the server.', text: window.i18n.t('errors.500_desc'),
confirmButtonText: 'Close', confirmButtonText: 'Close',
customClass: { customClass: {
popup: 'swal2-premium-card', popup: 'swal2-premium-card',

View File

@@ -54,8 +54,18 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<?php else: ?> <?php else: ?>
<?php foreach ($packages as $pkg): ?> <?php foreach ($packages as $pkg): ?>
<tr class="table-row-item group" <tr class="table-row-item group"
data-name="<?= strtolower($pkg['name']) ?>" data-id="<?= htmlspecialchars($pkg['id']) ?>"
data-price="<?= $pkg['price'] ?>"> data-name="<?= htmlspecialchars($pkg['name']) ?>"
data-profile="<?= htmlspecialchars($pkg['profile']) ?>"
data-prefix="<?= htmlspecialchars($pkg['prefix']) ?>"
data-price="<?= htmlspecialchars($pkg['price']) ?>"
data-selling-price="<?= htmlspecialchars($pkg['selling_price'] ?? $pkg['price']) ?>"
data-time-limit="<?= htmlspecialchars($pkg['time_limit']) ?>"
data-data-limit="<?= htmlspecialchars($pkg['data_limit']) ?>"
data-char-length="<?= htmlspecialchars($pkg['char_length']) ?>"
data-color="<?= htmlspecialchars($pkg['color']) ?>"
data-comment="<?= htmlspecialchars($pkg['comment']) ?>">
<td class="font-medium text-foreground"> <td class="font-medium text-foreground">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full <?= htmlspecialchars($pkg['color']) ?>"></div> <div class="w-3 h-3 rounded-full <?= htmlspecialchars($pkg['color']) ?>"></div>
@@ -76,7 +86,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<i data-lucide="trash-2" class="w-4 h-4"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</form> </form>
<button type="button" class="btn-icon" title="Edit"> <button type="button" onclick="openModal('edit', this)" class="btn-icon" title="Edit">
<i data-lucide="edit-3" class="w-4 h-4"></i> <i data-lucide="edit-3" class="w-4 h-4"></i>
</button> </button>
</div> </div>
@@ -101,96 +111,84 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</div> </div>
</div> </div>
<!-- Add/Edit Modal --> <!-- Template for Add/Edit Package Form -->
<div id="modal-overlay" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center opacity-0 transition-opacity duration-200"> <template id="package-form-template">
<div id="modal-content" class="card w-full max-w-lg mx-4 transform scale-95 transition-transform duration-200 overflow-hidden p-0"> <form id="qp-form" action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="space-y-4 text-left">
<div class="flex items-center justify-between px-6 py-4 border-b border-accents-2 bg-accents-1/30"> <input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
<h3 class="text-lg font-bold text-foreground" id="modal-title" data-i18n="quick_print.add_package">Add Package</h3> <!-- Hidden ID for Edit Mode (will be disabled/removed for Add) -->
<button onclick="closeModal()" class="text-accents-5 hover:text-foreground"> <input type="hidden" name="id" id="form-id" disabled>
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="p-6 space-y-4"> <!-- Quick Inputs Grid -->
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="col-span-1 md:col-span-2">
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label>
<input type="text" name="name" required class="w-full" placeholder="e.g. 3 Hours Voucher">
</div>
<!-- Quick Inputs Grid --> <div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
<div class="col-span-1 md:col-span-2"> <select name="profile" class="w-full" data-search="true">
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label> <?php foreach($profiles as $p): ?>
<input type="text" name="name" required class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary placeholder:text-accents-3" placeholder="e.g. 3 Hours Voucher"> <option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
</div> <?php endforeach; ?>
</select>
<div>
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
<select name="profile" class="custom-select w-full" data-search="true">
<?php foreach($profiles as $p): ?>
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
<select name="color" class="custom-select w-full">
<option value="bg-blue-500" data-i18n="colors.blue">Blue</option>
<option value="bg-red-500" data-i18n="colors.red">Red</option>
<option value="bg-green-500" data-i18n="colors.green">Green</option>
<option value="bg-yellow-500" data-i18n="colors.yellow">Yellow</option>
<option value="bg-purple-500" data-i18n="colors.purple">Purple</option>
<option value="bg-pink-500" data-i18n="colors.pink">Pink</option>
<option value="bg-indigo-500" data-i18n="colors.indigo">Indigo</option>
<option value="bg-gray-800" data-i18n="colors.dark">Dark</option>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.price">Price (Rp)</label>
<input type="number" name="price" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="5000">
</div>
<div>
<label class="form-label" data-i18n="quick_print.selling_price">Selling Price</label>
<input type="number" name="selling_price" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="Default same">
</div>
<div>
<label class="form-label" data-i18n="quick_print.prefix">Prefix</label>
<input type="text" name="prefix" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="Example: VIP-">
</div>
<div>
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
<select name="char_length" class="custom-select w-full">
<option value="4" selected data-i18n="common.char_length" data-i18n-params='{"n": 4}'>4 Characters</option>
<option value="6" data-i18n="common.char_length" data-i18n-params='{"n": 6}'>6 Characters</option>
<option value="8" data-i18n="common.char_length" data-i18n-params='{"n": 8}'>8 Characters</option>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.time_limit">Time Limit</label>
<input type="text" name="time_limit" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="3h">
</div>
<div>
<label class="form-label" data-i18n="quick_print.data_limit">Data Limit</label>
<input type="text" name="data_limit" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="500M (Optional)">
</div>
<div class="col-span-1 md:col-span-2">
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
<input type="text" name="comment" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="Description or Note">
</div>
</div> </div>
<div class="flex justify-end gap-3 pt-4 border-t border-accents-2 mt-4"> <div>
<button type="button" onclick="closeModal()" class="btn btn-secondary" data-i18n="common.cancel">Cancel</button> <label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
<button type="submit" class="btn btn-primary" data-i18n="quick_print.save_package">Save Package</button> <select name="color" class="w-full">
<option value="bg-blue-500" data-i18n="colors.blue">Blue</option>
<option value="bg-red-500" data-i18n="colors.red">Red</option>
<option value="bg-green-500" data-i18n="colors.green">Green</option>
<option value="bg-yellow-500" data-i18n="colors.yellow">Yellow</option>
<option value="bg-purple-500" data-i18n="colors.purple">Purple</option>
<option value="bg-pink-500" data-i18n="colors.pink">Pink</option>
<option value="bg-indigo-500" data-i18n="colors.indigo">Indigo</option>
<option value="bg-gray-800" data-i18n="colors.dark">Dark</option>
</select>
</div> </div>
</form>
</div> <div>
</div> <label class="form-label" data-i18n="quick_print.price">Price (Rp)</label>
<input type="number" name="price" class="w-full" placeholder="5000">
</div>
<div>
<label class="form-label" data-i18n="quick_print.selling_price">Selling Price</label>
<input type="number" name="selling_price" class="w-full" placeholder="Default same">
</div>
<div>
<label class="form-label" data-i18n="quick_print.prefix">Prefix</label>
<input type="text" name="prefix" class="w-full" placeholder="Example: VIP-">
</div>
<div>
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
<select name="char_length" class="w-full">
<option value="4" selected data-i18n="common.char_length" data-i18n-params='{"n": 4}'>4 Characters</option>
<option value="6" data-i18n="common.char_length" data-i18n-params='{"n": 6}'>6 Characters</option>
<option value="8" data-i18n="common.char_length" data-i18n-params='{"n": 8}'>8 Characters</option>
</select>
</div>
<div>
<label class="form-label" data-i18n="quick_print.time_limit">Time Limit</label>
<input type="text" name="time_limit" class="w-full" placeholder="3h">
</div>
<div>
<label class="form-label" data-i18n="quick_print.data_limit">Data Limit</label>
<input type="text" name="data_limit" class="w-full" placeholder="500M (Optional)">
</div>
<div class="col-span-1 md:col-span-2">
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
<input type="text" name="comment" class="w-full" placeholder="Description or Note">
</div>
</div>
</form>
</template>
<script> <script>
class TableManager { class TableManager {
@@ -298,27 +296,63 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
} }
} }
const overlay = document.getElementById('modal-overlay'); function openModal(mode, btn = null) {
const content = document.getElementById('modal-content'); const template = document.getElementById('package-form-template').innerHTML;
let title = window.i18n ? window.i18n.t('quick_print.add_package') : 'Add Package';
let saveBtn = window.i18n ? window.i18n.t('quick_print.save_package') : 'Save Package';
// Validation Callback
const preConfirmFn = () => {
const form = Swal.getHtmlContainer().querySelector('form');
if(form.reportValidity()) {
form.submit();
} else {
return false;
}
};
function openModal(mode) { // Population Callback (Runs BEFORE CustomSelect init)
overlay.classList.remove('hidden'); const onOpenedFn = (popup) => {
// Trigger reflow if (mode === 'edit' && btn) {
void overlay.offsetWidth; const row = btn.closest('tr');
const form = popup.querySelector('form');
overlay.classList.remove('opacity-0');
content.classList.add('open'); // Update Route Logic Here if needed, or rely on Hidden ID
// For now backend handles update if ID is present
if (typeof lucide !== 'undefined') lucide.createIcons(); form.action = "/<?= htmlspecialchars($session) ?>/quick-print/update";
}
function closeModal() { // Populate inputs
overlay.classList.add('opacity-0'); form.querySelector('[name="id"]').value = row.dataset.id;
content.classList.remove('open'); form.querySelector('[name="id"]').disabled = false;
form.querySelector('[name="name"]').value = row.dataset.name;
form.querySelector('[name="price"]').value = row.dataset.price;
form.querySelector('[name="selling_price"]').value = row.dataset.sellingPrice;
form.querySelector('[name="prefix"]').value = row.dataset.prefix;
form.querySelector('[name="time_limit"]').value = row.dataset.timeLimit;
form.querySelector('[name="data_limit"]').value = row.dataset.dataLimit;
form.querySelector('[name="comment"]').value = row.dataset.comment;
// Selects (Just setting value works because CustomSelect hasn't init yet!)
const profileSel = form.querySelector('[name="profile"]');
if(profileSel) profileSel.value = row.dataset.profile;
const colorSel = form.querySelector('[name="color"]');
if(colorSel) colorSel.value = row.dataset.color;
const charSel = form.querySelector('[name="char_length"]');
if(charSel) charSel.value = row.dataset.charLength;
}
};
if (mode === 'edit' && btn) {
title = window.i18n ? 'Edit Package' : 'Edit Package';
saveBtn = window.i18n ? 'Update Package' : 'Update Package';
}
setTimeout(() => { // Pass callbacks to helper
overlay.classList.add('hidden'); Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
}, 300);
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

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

@@ -21,7 +21,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<!-- Spacer --> <!-- Spacer -->
</div> </div>
<div class="flex gap-2 w-full md:w-auto"> <div class="flex gap-2 w-full md:w-auto">
<button onclick="openModal('addModal')" class="btn btn-primary w-full md:w-auto"> <button onclick="openCorsModal()" class="btn btn-primary w-full md:w-auto">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.add_rule">Add CORS Rule</span> <i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.add_rule">Add CORS Rule</span>
</button> </button>
</div> </div>
@@ -40,7 +40,12 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<tbody id="table-body"> <tbody id="table-body">
<?php if (!empty($rules)): ?> <?php if (!empty($rules)): ?>
<?php foreach ($rules as $rule): ?> <?php foreach ($rules as $rule): ?>
<tr class="table-row-item"> <tr class="table-row-item"
data-rule-id="<?= $rule['id'] ?>"
data-origin="<?= htmlspecialchars($rule['origin']) ?>"
data-headers="<?= htmlspecialchars(implode(', ', $rule['headers_arr'])) ?>"
data-max-age="<?= $rule['max_age'] ?>"
data-methods='<?= json_encode($rule['methods_arr']) ?>'>
<td> <td>
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($rule['origin']) ?></div> <div class="text-sm font-medium text-foreground"><?= htmlspecialchars($rule['origin']) ?></div>
<div class="text-xs text-accents-4">Max Age: <?= $rule['max_age'] ?>s</div> <div class="text-xs text-accents-4">Max Age: <?= $rule['max_age'] ?>s</div>
@@ -57,7 +62,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</td> </td>
<td class="text-right text-sm font-medium"> <td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal"> <div class="flex items-center justify-end gap-2 table-actions-reveal">
<button onclick="editRule(<?= htmlspecialchars(json_encode($rule)) ?>)" class="btn-icon" title="Edit"> <button onclick="openCorsModal(this.closest('tr'))" class="btn-icon" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i> <i data-lucide="edit-2" class="w-4 h-4"></i>
</button> </button>
<form action="/settings/api-cors/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.cors_rule_deleted') : 'Delete CORS Rule?', 'Are you sure you want to delete the CORS rule for <?= htmlspecialchars($rule['origin']) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline"> <form action="/settings/api-cors/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.cors_rule_deleted') : 'Delete CORS Rule?', 'Are you sure you want to delete the CORS rule for <?= htmlspecialchars($rule['origin']) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
@@ -85,136 +90,71 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</div> </div>
</div> </div>
<!-- Add Modal -->
<div id="addModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('addModal')"></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
<div class="card shadow-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold" data-i18n="settings.add_rule">Add CORS Rule</h3>
<button onclick="closeModal('addModal')" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/settings/api-cors/store" method="POST" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
<input type="text" name="origin" class="form-control" placeholder="https://example.com or *" required>
<p class="text-xs text-orange-500 dark:text-orange-400 mt-1 font-medium">Use * for all origins (not recommended for production).</p>
</div>
<div>
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
<div class="grid grid-cols-3 gap-2">
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox" <?= in_array($m, ['GET', 'POST']) ? 'checked' : '' ?>>
<span class="text-sm"><?= $m ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
<input type="text" name="headers" class="form-control" value="*" placeholder="Content-Type, Authorization, *">
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
<input type="number" name="max_age" class="form-control" value="3600">
</div>
<div class="flex justify-end pt-4">
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Modal -->
<div id="editModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('editModal')"></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
<div class="card shadow-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold" data-i18n="settings.edit_rule">Edit CORS Rule</h3>
<button onclick="closeModal('editModal')" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/settings/api-cors/update" method="POST" class="space-y-4">
<input type="hidden" name="id" id="edit_id">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
<input type="text" name="origin" id="edit_origin" class="form-control" required>
</div>
<div>
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
<div class="grid grid-cols-3 gap-2" id="edit_methods_container">
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox edit-method-check" data-method="<?= $m ?>">
<span class="text-sm"><?= $m ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
<input type="text" name="headers" id="edit_headers" class="form-control">
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
<input type="number" name="max_age" id="edit_max_age" class="form-control">
</div>
<div class="flex justify-end pt-4">
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<script> <script>
function openModal(id) { async function openCorsModal(row = null) {
const modal = document.getElementById(id); const isEdit = !!row;
const content = modal.querySelector('.modal-content'); const title = isEdit ? (window.i18n ? window.i18n.t('settings.edit_rule') : 'Edit CORS Rule') : (window.i18n ? window.i18n.t('settings.add_rule') : 'Add CORS Rule');
modal.classList.remove('hidden'); const template = document.getElementById('cors-form-template').innerHTML;
const saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
// Use double requestAnimationFrame to ensure the browser has painted the hidden->block change
// before we trigger the opacity/transform transitions.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
modal.classList.remove('opacity-0');
content.classList.remove('scale-95', 'opacity-0');
content.classList.add('scale-100', 'opacity-100');
});
});
}
function closeModal(id) { const preConfirmFn = () => {
const modal = document.getElementById(id); const form = document.getElementById('cors-form');
const content = modal.querySelector('.modal-content'); if (!form.checkValidity()) {
modal.classList.add('opacity-0'); form.reportValidity();
content.classList.remove('scale-100', 'opacity-100'); return false;
content.classList.add('scale-95', 'opacity-0'); }
setTimeout(() => { modal.classList.add('hidden'); }, 300); form.submit();
} return true;
};
function editRule(rule) { const onOpenedFn = (popup) => {
document.getElementById('edit_id').value = rule.id; const form = popup.querySelector('#cors-form');
document.getElementById('edit_origin').value = rule.origin; if (isEdit) {
document.getElementById('edit_headers').value = rule.headers_arr.join(', '); form.action = '/settings/api-cors/update';
document.getElementById('edit_max_age').value = rule.max_age; form.querySelector('[name="id"]').value = row.dataset.ruleId;
form.querySelector('[name="origin"]').value = row.dataset.origin;
// Clear and check checkboxes form.querySelector('[name="headers"]').value = row.dataset.headers;
const methods = rule.methods_arr; form.querySelector('[name="max_age"]').value = row.dataset.maxAge;
document.querySelectorAll('.edit-method-check').forEach(cb => {
cb.checked = methods.includes(cb.dataset.method); const methods = JSON.parse(row.dataset.methods || '[]');
}); form.querySelectorAll('[name="methods[]"]').forEach(cb => {
cb.checked = methods.includes(cb.value);
openModal('editModal'); });
} }
};
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
}
</script> </script>
<template id="cors-form-template">
<form action="/settings/api-cors/store" method="POST" id="cors-form" class="space-y-4 text-left">
<input type="hidden" name="id">
<div>
<label class="form-label" data-i18n="settings.origin">Origin</label>
<input type="text" name="origin" class="w-full" placeholder="https://example.com or *" required>
<p class="text-[10px] text-orange-500 dark:text-orange-400 mt-1 font-medium">Use * for all origins (not recommended for production).</p>
</div>
<div>
<label class="form-label" data-i18n="settings.methods">Allowed Methods</label>
<div class="grid grid-cols-3 gap-2">
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="checkbox" <?= in_array($m, ['GET', 'POST']) ? 'checked' : '' ?>>
<span class="text-sm font-medium"><?= $m ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div>
<label class="form-label" data-i18n="settings.headers">Allowed Headers</label>
<input type="text" name="headers" class="w-full" value="*" placeholder="Content-Type, Authorization, *">
</div>
<div>
<label class="form-label" data-i18n="settings.max_age">Max Age (seconds)</label>
<input type="number" name="max_age" class="w-full" value="3600">
</div>
</form>
</template>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?> <?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

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

View File

@@ -22,9 +22,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<div class="hidden md:block"> <div class="hidden md:block">
<!-- Spacer or Breadcrumbs if needed --> <!-- Spacer or Breadcrumbs if needed -->
</div> </div>
<a href="/settings/add" class="btn btn-primary w-full md:w-auto"> <button onclick="openRouterModal('add')" class="btn btn-primary w-full md:w-auto">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add Router <i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="routers.add_router_title">Add Router</span>
</a> </button>
</div> </div>
<?php if (empty($routers)): ?> <?php if (empty($routers)): ?>
@@ -34,9 +34,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</div> </div>
<h3 class="text-lg font-medium mb-2">No routers configured</h3> <h3 class="text-lg font-medium mb-2">No routers configured</h3>
<p class="text-accents-5 mb-6 max-w-sm mx-auto">Connect your first MikroTik router to start managing hotspots and vouchers.</p> <p class="text-accents-5 mb-6 max-w-sm mx-auto">Connect your first MikroTik router to start managing hotspots and vouchers.</p>
<a href="/settings/add" class="btn btn-primary"> <button onclick="openRouterModal('add')" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Connect Router <i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="routers.add_router_title">Connect Router</span>
</a> </button>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="table-container"> <div class="table-container">
@@ -53,7 +53,17 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</thead> </thead>
<tbody> <tbody>
<?php foreach ($routers as $router): ?> <?php foreach ($routers as $router): ?>
<tr> <tr class="router-row"
data-id="<?= $router['id'] ?>"
data-sessname="<?= htmlspecialchars($router['session_name']) ?>"
data-ipmik="<?= htmlspecialchars($router['ip_address']) ?>"
data-usermik="<?= htmlspecialchars($router['username']) ?>"
data-hotspotname="<?= htmlspecialchars($router['hotspot_name']) ?>"
data-dnsname="<?= htmlspecialchars($router['dns_name']) ?>"
data-iface="<?= htmlspecialchars($router['interface'] ?? 'ether1') ?>"
data-currency="<?= htmlspecialchars($router['currency'] ?? 'Rp') ?>"
data-areload="<?= htmlspecialchars($router['reload_interval'] ?? '10') ?>"
data-quick-access="<?= $router['quick_access'] ?? 0 ?>">
<td> <td>
<div class="flex items-center"> <div class="flex items-center">
<div class="h-8 w-8 rounded bg-accents-2 flex items-center justify-center text-xs font-bold mr-3"> <div class="h-8 w-8 rounded bg-accents-2 flex items-center justify-center text-xs font-bold mr-3">
@@ -80,9 +90,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<a href="/<?= htmlspecialchars($router['session_name']) ?>/dashboard" class="btn btn-secondary btn-sm h-8 px-3"> <a href="/<?= htmlspecialchars($router['session_name']) ?>/dashboard" class="btn btn-secondary btn-sm h-8 px-3">
Open Open
</a> </a>
<a href="/settings/edit/<?= $router['id'] ?>" class="btn btn-secondary btn-sm h-8 px-3" title="Edit"> <button onclick="openRouterModal('edit', this)" class="btn btn-secondary btn-sm h-8 px-3" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i> <i data-lucide="edit-2" class="w-4 h-4"></i>
</a> </button>
<form action="/settings/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Disconnect Router?', 'Are you sure you want to disconnect <?= htmlspecialchars($router['session_name']) ?>?', 'Disconnect', 'Cancel').then(res => { if(res) this.submit(); });" class="inline"> <form action="/settings/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Disconnect Router?', 'Are you sure you want to disconnect <?= htmlspecialchars($router['session_name']) ?>?', 'Disconnect', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
<input type="hidden" name="id" value="<?= $router['id'] ?>"> <input type="hidden" name="id" value="<?= $router['id'] ?>">
<button type="submit" class="btn hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 border border-transparent h-8 px-2" title="Delete"> <button type="submit" class="btn hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 border border-transparent h-8 px-2" title="Delete">
@@ -98,13 +108,239 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<div class="text-sm text-accents-5"> <div class="text-sm text-accents-5">
Showing all <?= count($routers) ?> stored sessions Showing all <?= count($routers) ?> stored sessions
</div> </div>
<a href="/settings/add" class="btn btn-primary btn-sm w-full sm:w-auto justify-center"> <button onclick="openRouterModal('add')" class="btn btn-primary btn-sm w-full sm:w-auto justify-center">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add New <i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="routers.add_router_title">Add New</span>
</a> </button>
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<template id="router-form-template">
<div class="text-left">
<form id="router-form" action="/settings/store" method="POST" class="space-y-6">
<input type="hidden" name="id" id="form-id">
<!-- Session Settings -->
<div>
<h2 class="text-base font-semibold mb-3 flex items-center gap-2" data-i18n="routers.session_settings">
<i data-lucide="settings" class="w-4 h-4"></i> Session Settings
</h2>
<div class="max-w-md space-y-4">
<div class="space-y-1">
<label class="form-label" data-i18n="home.session_name">Session Name</label>
<input class="w-full" type="text" name="sessname" id="sessname" placeholder="e.g. router-jakarta-1" required/>
<p class="text-[10px] text-accents-4 uppercase tracking-tighter mt-1">
<span data-i18n="routers.unique_id">Unique ID:</span> <span id="sessname-preview" class="font-mono text-primary font-bold">...</span>
</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="quick_access" name="quick_access" class="checkbox flex-shrink-0" value="1">
<label for="quick_access" class="text-xs font-bold cursor-pointer select-none whitespace-nowrap uppercase tracking-wider" data-i18n="routers.show_quick_access">Show in Quick Access</label>
</div>
</div>
</div>
<!-- Connection Details -->
<div class="border-t border-white/5 pt-6">
<h2 class="text-base font-semibold mb-3 flex items-center gap-2" data-i18n="routers.connection_details">
<i data-lucide="zap" class="w-4 h-4"></i> Connection Details
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="space-y-1 md:col-span-1">
<label class="form-label" data-i18n="home.ip_address">IP Address</label>
<input class="w-full" type="text" name="ipmik" placeholder="192.168.88.1" required/>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="login.username">Username</label>
<input class="w-full" type="text" name="usermik" placeholder="admin" required/>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="login.password">Password</label>
<input class="w-full" type="password" name="passmik" id="passmik" placeholder="••••••••"/>
</div>
</div>
</div>
<!-- Hotspot Information -->
<div class="border-t border-white/5 pt-6">
<h2 class="text-base font-semibold mb-3 flex items-center gap-2" data-i18n="routers.hotspot_info">
<i data-lucide="globe" class="w-4 h-4"></i> Hotspot Information
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="home.hotspot_name">Hotspot Name</label>
<input class="w-full" type="text" name="hotspotname" placeholder="My Hotspot ID" required/>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="routers.dns_name">DNS Name</label>
<input class="w-full" type="text" name="dnsname" placeholder="hotspot.net" required/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="space-y-1">
<label class="form-label" data-i18n="routers.traffic_interface">Traffic Interface</label>
<div class="flex w-full gap-2">
<div class="flex-grow">
<select class="w-full" name="iface" id="iface" data-search="true" required>
<option value="ether1">ether1</option>
</select>
</div>
<button type="button" id="check-interface-btn" class="btn btn-secondary whitespace-nowrap px-3" title="Check connection">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-1"></i>
<span class="text-xs font-bold uppercase tracking-tight" data-i18n="routers.check_connection">Check Connection</span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="routers.currency">Currency</label>
<input class="w-full" type="text" name="currency" value="Rp" required/>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="routers.auto_reload">Reload (s)</label>
<input class="w-full" type="number" min="2" name="areload" value="10" required/>
</div>
</div>
</div>
</div>
</form>
</div>
</template>
<script>
function openRouterModal(mode, btn = null) {
const template = document.getElementById('router-form-template').innerHTML;
let title = window.i18n ? window.i18n.t('routers.add_router_title') : 'Add Router';
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
if (mode === 'edit') {
title = window.i18n ? window.i18n.t('routers.edit_router_title') : 'Edit Router';
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
}
const preConfirmFn = () => {
const form = Swal.getHtmlContainer().querySelector('form');
if(form.reportValidity()) {
form.submit();
return true;
}
return false;
};
const onOpenedFn = (popup) => {
const form = popup.querySelector('form');
// --- Interface Check Logic ---
const checkBtn = form.querySelector('#check-interface-btn');
const ifaceSelect = form.querySelector('#iface');
if (checkBtn && ifaceSelect) {
checkBtn.addEventListener('click', async () => {
const originalHTML = checkBtn.innerHTML;
checkBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 mr-1 animate-spin"></i><span class="text-xs font-bold uppercase tracking-tight">Checking...</span>';
checkBtn.disabled = true;
if (typeof lucide !== 'undefined') lucide.createIcons();
const ip = form.querySelector('[name="ipmik"]').value;
const user = form.querySelector('[name="usermik"]').value;
const pass = form.querySelector('[name="passmik"]').value;
const id = form.querySelector('[name="id"]').value || null;
if (!ip || !user) {
Mivo.toast('warning', 'Missing Details', 'IP Address and Username are required');
checkBtn.innerHTML = originalHTML;
checkBtn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
try {
const response = await fetch('/api/router/interfaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip, user, password: pass, id })
});
const data = await response.json();
if (!data.success || !data.interfaces) {
Mivo.toast('error', 'Fetch Failed', data.error || 'Check credentials');
} else {
ifaceSelect.innerHTML = '';
data.interfaces.forEach(iface => {
const opt = document.createElement('option');
opt.value = iface;
opt.textContent = iface;
ifaceSelect.appendChild(opt);
});
if (window.Mivo && window.Mivo.components.Select) {
const instance = window.Mivo.components.Select.get(ifaceSelect);
if (instance) instance.refresh();
}
Mivo.toast('success', 'Success', 'Interfaces loaded');
}
} catch (err) {
Mivo.toast('error', 'Error', 'Connection failed');
} finally {
checkBtn.innerHTML = originalHTML;
checkBtn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
});
}
// --- Session Name Formatting ---
const sessInput = form.querySelector('[name="sessname"]');
const sessPreview = form.querySelector('#sessname-preview');
if (sessInput && sessPreview) {
sessInput.addEventListener('input', (e) => {
let val = e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-');
e.target.value = val;
sessPreview.textContent = val || '...';
});
}
if (mode === 'edit' && btn) {
const row = btn.closest('tr');
form.action = "/settings/update";
const idInput = form.querySelector('#form-id');
idInput.disabled = false;
idInput.value = row.dataset.id;
form.querySelector('[name="sessname"]').value = row.dataset.sessname || '';
if(sessPreview) sessPreview.textContent = row.dataset.sessname || '';
form.querySelector('[name="ipmik"]').value = row.dataset.ipmik || '';
form.querySelector('[name="usermik"]').value = row.dataset.usermik || '';
form.querySelector('[name="hotspotname"]').value = row.dataset.hotspotname || '';
form.querySelector('[name="dnsname"]').value = row.dataset.dnsname || '';
form.querySelector('[name="currency"]').value = row.dataset.currency || 'Rp';
form.querySelector('[name="areload"]').value = row.dataset.areload || '10';
const quickCheck = form.querySelector('#quick_access');
if(quickCheck) quickCheck.checked = row.dataset.quickAccess == '1';
// Handle Interface Select
const currentIface = row.dataset.iface || 'ether1';
ifaceSelect.innerHTML = `<option value="${currentIface}" selected>${currentIface}</option>`;
if (window.Mivo && window.Mivo.components.Select) {
const instance = window.Mivo.components.Select.get(ifaceSelect);
if (instance) instance.refresh();
}
// Password is not populated for security, hint is in placeholder
form.querySelector('[name="passmik"]').placeholder = '•••••••• (unchanged)';
form.querySelector('[name="passmik"]').required = false;
}
};
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
}
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?> <?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -22,8 +22,8 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<!-- Upload Section --> <!-- Upload Section -->
<section> <section>
<div class="card p-8 border-dashed border-2 bg-accents-1 hover:bg-background transition-colors text-center relative group"> <div class="card p-8 border-dashed border-2 bg-accents-1 hover:bg-background transition-colors text-center relative group">
<form action="/settings/logos/upload" method="POST" enctype="multipart/form-data" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"> <form action="/settings/logos/upload" method="POST" enctype="multipart/form-data" class="absolute inset-0 w-full h-full cursor-pointer z-50">
<input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.svg,.gif" onchange="this.form.submit()" class="form-control-file"> <input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.svg,.gif" onchange="this.form.submit()" class="block w-full h-full opacity-0 cursor-pointer">
</form> </form>
<div class="flex flex-col items-center justify-center pointer-events-none"> <div class="flex flex-col items-center justify-center pointer-events-none">

View File

@@ -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,17 +12,24 @@ $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">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/settings/templates" class="text-accents-5 hover:text-foreground transition-colors"> <a href="/settings/voucher-templates" class="text-accents-5 hover:text-foreground transition-colors">
<i data-lucide="arrow-left" class="w-5 h-5"></i> <i data-lucide="arrow-left" class="w-5 h-5"></i>
</a> </a>
<h1 class="text-xl font-bold tracking-tight text-foreground"><?= $title ?></h1> <h1 class="text-xl font-bold tracking-tight text-foreground"><?= $title ?></h1>
</div> </div>
<form id="templateForm" action="<?= $isEdit ? '/settings/templates/update' : '/settings/templates/store' ?>" method="POST" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 w-full lg:w-auto"> <form id="templateForm" action="<?= $isEdit ? '/settings/voucher-templates/update' : '/settings/voucher-templates/store' ?>" method="POST" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 w-full lg:w-auto">
<?php if ($isEdit): ?> <?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= $template['id'] ?>"> <input type="hidden" name="id" value="<?= $template['id'] ?>">
<?php endif; ?> <?php endif; ?>
@@ -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

@@ -21,7 +21,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<div class="hidden md:block"> <div class="hidden md:block">
<!-- Spacer --> <!-- Spacer -->
</div> </div>
<a href="/settings/templates/add" class="btn btn-primary w-full sm:w-auto justify-center"> <a href="/settings/voucher-templates/add" class="btn btn-primary w-full sm:w-auto justify-center">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <i data-lucide="plus" class="w-4 h-4 mr-2"></i>
<span data-i18n="settings.new_template">New Template</span> <span data-i18n="settings.new_template">New Template</span>
</a> </a>
@@ -37,7 +37,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i> <i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
</div> </div>
<iframe <iframe
data-src="/settings/templates/preview/default" data-src="/settings/voucher-templates/preview/default"
src="about:blank" src="about:blank"
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500" class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
scrolling="no" scrolling="no"
@@ -66,7 +66,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i> <i data-lucide="loader-2" class="w-6 h-6 animate-spin text-accents-4"></i>
</div> </div>
<iframe <iframe
data-src="/settings/templates/preview/<?= $tpl['id'] ?>" data-src="/settings/voucher-templates/preview/<?= $tpl['id'] ?>"
src="about:blank" src="about:blank"
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500" class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
scrolling="no" scrolling="no"
@@ -87,10 +87,10 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<p class="text-sm text-accents-5 mb-4 line-clamp-1">Created: <?= htmlspecialchars($tpl['created_at']) ?></p> <p class="text-sm text-accents-5 mb-4 line-clamp-1">Created: <?= htmlspecialchars($tpl['created_at']) ?></p>
<div class="flex items-center gap-2 mt-auto"> <div class="flex items-center gap-2 mt-auto">
<a href="/settings/templates/edit/<?= $tpl['id'] ?>" class="flex-1 btn btn-primary flex justify-center"> <a href="/settings/voucher-templates/edit/<?= $tpl['id'] ?>" class="flex-1 btn btn-primary flex justify-center">
<i data-lucide="edit-3" class="w-4 h-4 mr-2"></i> <span data-i18n="common.edit">Edit</span> <i data-lucide="edit-3" class="w-4 h-4 mr-2"></i> <span data-i18n="common.edit">Edit</span>
</a> </a>
<form action="/settings/templates/delete" method="POST" class="delete-template-form"> <form action="/settings/voucher-templates/delete" method="POST" class="delete-template-form">
<input type="hidden" name="id" value="<?= $tpl['id'] ?>"> <input type="hidden" name="id" value="<?= $tpl['id'] ?>">
<input type="hidden" name="template_name" value="<?= htmlspecialchars($tpl['name']) ?>"> <input type="hidden" name="template_name" value="<?= htmlspecialchars($tpl['name']) ?>">
<button type="submit" class="p-2 btn btn-secondary hover:text-red-600 hover:bg-red-50 transition-colors h-9 w-9 flex items-center justify-center"> <button type="submit" class="p-2 btn btn-secondary hover:text-red-600 hover:bg-red-50 transition-colors h-9 w-9 flex items-center justify-center">

View File

@@ -12,7 +12,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<button onclick="location.reload()" class="btn btn-secondary"> <button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span> <i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button> </button>
<button onclick="openModal('addModal')" class="btn btn-primary"> <button onclick="openSchedulerModal('add')" class="btn btn-primary">
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="system_tools.add_task">Add Task</span> <i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="system_tools.add_task">Add Task</span>
</button> </button>
</div> </div>
@@ -54,7 +54,14 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
$status = ($task['disabled'] === 'true') ? 'disabled' : 'enabled'; $status = ($task['disabled'] === 'true') ? 'disabled' : 'enabled';
?> ?>
<tr class="table-row-item" <tr class="table-row-item"
data-name="<?= strtolower($task['name']) ?>" data-id="<?= $task['.id'] ?>"
data-name="<?= htmlspecialchars($task['name']) ?>"
data-interval="<?= htmlspecialchars($task['interval']) ?>"
data-start-date="<?= htmlspecialchars($task['start-date'] ?? '') ?>"
data-start-time="<?= htmlspecialchars($task['start-time'] ?? '') ?>"
data-on-event="<?= htmlspecialchars($task['on-event']) ?>"
data-comment="<?= htmlspecialchars($task['comment'] ?? '') ?>"
data-search-name="<?= strtolower($task['name']) ?>"
data-status="<?= $status ?>"> data-status="<?= $status ?>">
<td> <td>
@@ -72,7 +79,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</td> </td>
<td class="text-right text-sm font-medium"> <td class="text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2 table-actions-reveal"> <div class="flex items-center justify-end gap-2 table-actions-reveal">
<button onclick="editTask(<?= htmlspecialchars(json_encode($task)) ?>)" class="btn-icon" title="Edit"> <button onclick="openSchedulerModal('edit', this)" class="btn-icon" title="Edit">
<i data-lucide="edit-2" class="w-4 h-4"></i> <i data-lucide="edit-2" class="w-4 h-4"></i>
</button> </button>
<form action="/<?= $session ?>/system/scheduler/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('system_tools.delete_task') : 'Delete Task?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete task <?= htmlspecialchars($task['name']) ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline"> <form action="/<?= $session ?>/system/scheduler/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('system_tools.delete_task') : 'Delete Task?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete task <?= htmlspecialchars($task['name']) ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
@@ -103,104 +110,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
</div> </div>
</div> </div>
<!-- Add Modal -->
<div id="addModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('addModal')"></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
<div class="card shadow-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold" data-i18n="system_tools.add_title">Add Scheduler Task</h3>
<button onclick="closeModal('addModal')" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/<?= $session ?>/system/scheduler/store" method="POST" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.name">Name</label>
<input type="text" name="name" class="form-control" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.interval">Interval</label>
<input type="text" name="interval" class="form-control" value="1d 00:00:00" placeholder="1d 00:00:00">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_date">Start Date</label>
<input type="text" name="start_date" class="form-control" value="Jan/01/1970">
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_time">Start Time</label>
<input type="text" name="start_time" class="form-control" value="00:00:00">
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.on_event">On Event (Script)</label>
<textarea name="on_event" class="form-control font-mono text-xs h-24" placeholder="/system reboot"></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.comment">Comment</label>
<input type="text" name="comment" class="form-control">
</div>
<div class="flex justify-end pt-4">
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="system_tools.save_task">Save Task</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Modal -->
<div id="editModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('editModal')"></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
<div class="card shadow-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold" data-i18n="system_tools.edit_title">Edit Scheduler Task</h3>
<button onclick="closeModal('editModal')" class="text-accents-5 hover:text-foreground">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form action="/<?= $session ?>/system/scheduler/update" method="POST" class="space-y-4">
<input type="hidden" name="id" id="edit_id">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.name">Name</label>
<input type="text" name="name" id="edit_name" class="form-control" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.interval">Interval</label>
<input type="text" name="interval" id="edit_interval" class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_date">Start Date</label>
<input type="text" name="start_date" id="edit_start_date" class="form-control">
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_time">Start Time</label>
<input type="text" name="start_time" id="edit_start_time" class="form-control">
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.on_event">On Event (Script)</label>
<textarea name="on_event" id="edit_on_event" class="form-control font-mono text-xs h-24"></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.comment">Comment</label>
<input type="text" name="comment" id="edit_comment" class="form-control">
</div>
<div class="flex justify-end pt-4">
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="system_tools.update_task">Update Task</button>
</div>
</form>
</div>
</div>
</div>
<script> <script>
class TableManager { class TableManager {
@@ -256,7 +166,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
update() { update() {
this.filteredRows = this.allRows.filter(row => { this.filteredRows = this.allRows.filter(row => {
const name = row.dataset.name || ''; const name = row.dataset.searchName || '';
if (this.filters.search && !name.includes(this.filters.search)) return false; if (this.filters.search && !name.includes(this.filters.search)) return false;
@@ -308,42 +218,49 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
} }
} }
function openModal(id) { function openSchedulerModal(mode, btn = null) {
const modal = document.getElementById(id); const template = document.getElementById('scheduler-form-template').innerHTML;
const content = modal.querySelector('.modal-content');
modal.classList.remove('hidden'); let title = window.i18n ? window.i18n.t('system_tools.add_title') : 'Add Scheduler Task';
// Force reflow let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
void modal.offsetWidth;
modal.classList.remove('opacity-0'); if (mode === 'edit') {
content.classList.remove('scale-95', 'opacity-0'); title = window.i18n ? window.i18n.t('system_tools.edit_title') : 'Edit Scheduler Task';
content.classList.add('scale-100', 'opacity-100'); saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
} }
function closeModal(id) { const preConfirmFn = () => {
const modal = document.getElementById(id); const form = Swal.getHtmlContainer().querySelector('form');
const content = modal.querySelector('.modal-content'); if(form.reportValidity()) {
form.submit();
modal.classList.add('opacity-0'); return true;
content.classList.remove('scale-100', 'opacity-100'); }
content.classList.add('scale-95', 'opacity-0'); return false;
};
setTimeout(() => {
modal.classList.add('hidden');
}, 300); // Match duration-300
}
function editTask(task) { const onOpenedFn = (popup) => {
document.getElementById('edit_id').value = task['.id']; const form = popup.querySelector('form');
document.getElementById('edit_name').value = task['name'];
document.getElementById('edit_interval').value = task['interval']; if (mode === 'edit' && btn) {
document.getElementById('edit_start_date').value = task['start-date']; const row = btn.closest('tr');
document.getElementById('edit_start_time').value = task['start-time']; form.action = "/<?= htmlspecialchars($session) ?>/system/scheduler/update";
document.getElementById('edit_on_event').value = task['on-event'];
document.getElementById('edit_comment').value = task['comment'] ?? ''; // Populate Hidden ID
const idInput = form.querySelector('#form-id');
openModal('editModal'); idInput.disabled = false;
idInput.value = row.dataset.id;
// Populate Fields
form.querySelector('[name="name"]').value = row.dataset.name || '';
form.querySelector('[name="interval"]').value = row.dataset.interval || '';
form.querySelector('[name="start_date"]').value = row.dataset.startDate || '';
form.querySelector('[name="start_time"]').value = row.dataset.startTime || '';
form.querySelector('[name="on_event"]').value = row.dataset.onEvent || '';
form.querySelector('[name="comment"]').value = row.dataset.comment || '';
}
};
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -351,4 +268,40 @@ function editTask(task) {
}); });
</script> </script>
<template id="scheduler-form-template">
<div class="text-left">
<form action="/<?= $session ?>/system/scheduler/store" method="POST" class="space-y-4">
<input type="hidden" name="id" id="form-id" disabled>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="system_tools.name">Name</label>
<input type="text" name="name" class="w-full" required>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="system_tools.interval">Interval</label>
<input type="text" name="interval" class="w-full" value="1d 00:00:00" placeholder="1d 00:00:00">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="form-label" data-i18n="system_tools.start_date">Start Date</label>
<input type="text" name="start_date" class="w-full" value="Jan/01/1970">
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="system_tools.start_time">Start Time</label>
<input type="text" name="start_time" class="w-full" value="00:00:00">
</div>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="system_tools.on_event">On Event (Script)</label>
<textarea name="on_event" class="w-full font-mono text-xs h-32" placeholder="/system reboot"></textarea>
</div>
<div class="space-y-1">
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
<input type="text" name="comment" class="w-full">
</div>
</form>
</div>
</template>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?> <?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

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

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

View File

@@ -0,0 +1,36 @@
version: '3.8'
services:
mivo:
image: ghcr.io/mivodev/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

25
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
set -e
# Ensure Database directory exists
mkdir -p /var/www/html/app/Database
# Fix permissions for the Database directory
# This is crucial for SQLite when volumes are mounted from host
if [ -d "/var/www/html/app/Database" ]; then
chown -R www-data:www-data /var/www/html/app/Database
chmod -R 775 /var/www/html/app/Database
fi
# Also ensure .env is writable if it exists, or create it from example
if [ ! -f "/var/www/html/.env" ] && [ -f "/var/www/html/.env.example" ]; then
cp /var/www/html/.env.example /var/www/html/.env
chown www-data:www-data /var/www/html/.env
fi
if [ -f "/var/www/html/.env" ]; then
chmod 664 /var/www/html/.env
fi
# Execute the command passed to docker run (usually supervisor)
exec "$@"

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

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