mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 13:31:56 +07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c92985707 | ||
|
|
d4691bc700 | ||
|
|
74b258b12d | ||
|
|
d5939dc5a2 | ||
|
|
c95c8b08ea | ||
|
|
b245f31236 | ||
|
|
e8ffea2c58 | ||
|
|
a4d0233386 | ||
|
|
95ca189679 | ||
|
|
5b0b6de2dc | ||
|
|
64609a5821 | ||
|
|
08960b540f | ||
|
|
d8c1a779b8 | ||
|
|
4968246911 | ||
|
|
ae65ab30fa | ||
|
|
6eb6bbb359 | ||
|
|
7a0c6cb5c3 |
@@ -10,3 +10,4 @@ docs/
|
|||||||
app/Database/*.sqlite
|
app/Database/*.sqlite
|
||||||
public/assets/img/logos/*
|
public/assets/img/logos/*
|
||||||
!public/assets/img/logos/.gitignore
|
!public/assets/img/logos/.gitignore
|
||||||
|
CNAME
|
||||||
|
|||||||
12
.gitattributes
vendored
12
.gitattributes
vendored
@@ -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
|
||||||
55
.github/workflows/deploy-docs.yml
vendored
55
.github/workflows/deploy-docs.yml
vendored
@@ -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
|
|
||||||
26
.github/workflows/docker-publish.yml
vendored
26
.github/workflows/docker-publish.yml
vendored
@@ -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)
|
||||||
|
|||||||
67
.github/workflows/release.yml
vendored
Normal file
67
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Create Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.2'
|
||||||
|
extensions: mbstring, xml, ctype, iconv, sqlite3, openssl
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: get_version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Release Directory
|
||||||
|
run: |
|
||||||
|
mkdir release_temp
|
||||||
|
# Export source using git archive (respects .gitattributes)
|
||||||
|
git archive --format=tar HEAD | tar -x -C release_temp
|
||||||
|
|
||||||
|
- name: Install Development Dependencies (for Build)
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build Localized Assets & Editor Bundle
|
||||||
|
run: |
|
||||||
|
npm run sync:assets
|
||||||
|
npm run build:editor
|
||||||
|
|
||||||
|
- 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
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -17,6 +17,7 @@ Thumbs.db
|
|||||||
# Build Artifacts & Deployments
|
# Build Artifacts & Deployments
|
||||||
/deploy_package.tar.gz
|
/deploy_package.tar.gz
|
||||||
/mivo_backup_*.mivo
|
/mivo_backup_*.mivo
|
||||||
|
/mivo-*.zip
|
||||||
|
|
||||||
# Secrets and Environment
|
# Secrets and Environment
|
||||||
.env
|
.env
|
||||||
@@ -27,4 +28,8 @@ docs/.vitepress/cache
|
|||||||
|
|
||||||
# Build Scripts & Artifacts
|
# Build Scripts & Artifacts
|
||||||
build_release.ps1
|
build_release.ps1
|
||||||
*.zip
|
deploy.ps1
|
||||||
|
|
||||||
|
# User Uploads
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitignore
|
||||||
|
|||||||
@@ -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
|
ghcr.io/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: ghcr.io/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
|
|||||||
[](https://sociabuzz.com/dyzulkdev/tribe)
|
[](https://sociabuzz.com/dyzulkdev/tribe)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Created by DyzulkDev*
|
*Created by MivoDev*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ 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 ghcr.io/mivodev/mivo
|
||||||
> ```
|
> ```
|
||||||
> *See [INSTALLATION.md](docs/INSTALLATION.md) for more tags.*
|
> *See [INSTALLATION.md](docs/INSTALLATION.md) for more tags.*
|
||||||
|
|
||||||
@@ -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
82
aapanel_deploy.sh
Normal 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 "---------------------------------------"
|
||||||
@@ -3,12 +3,12 @@ namespace App\Config;
|
|||||||
|
|
||||||
class SiteConfig {
|
class SiteConfig {
|
||||||
const APP_NAME = 'MIVO';
|
const APP_NAME = 'MIVO';
|
||||||
const APP_VERSION = 'v1.0';
|
const APP_VERSION = 'v1.1.1';
|
||||||
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
||||||
const CREDIT_NAME = 'DyzulkDev';
|
const CREDIT_NAME = 'MivoDev';
|
||||||
const CREDIT_URL = 'https://dyzulk.com';
|
const CREDIT_URL = 'https://github.com/mivodev';
|
||||||
const YEAR = '2026';
|
const YEAR = '2026';
|
||||||
const REPO_URL = 'https://github.com/dyzulk/mivo';
|
const REPO_URL = 'https://github.com/mivodev/mivo';
|
||||||
|
|
||||||
// Security Keys
|
// Security Keys
|
||||||
// Fetched from .env or fallback to default
|
// Fetched from .env or fallback to default
|
||||||
|
|||||||
@@ -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'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ class InstallController extends Controller {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->view('install');
|
$permissions = $this->checkPermissions();
|
||||||
|
|
||||||
|
return $this->view('install', [
|
||||||
|
'permissions' => $permissions
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function process() {
|
public function process() {
|
||||||
@@ -25,6 +29,13 @@ class InstallController extends Controller {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$permissions = $this->checkPermissions();
|
||||||
|
if (!$permissions['db_writable'] || !$permissions['root_writable']) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'Izin Ditolak', 'Pastikan folder app/Database dan root direktori dapat ditulis oleh server web.');
|
||||||
|
header('Location: /install');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$username = $_POST['username'] ?? 'admin';
|
$username = $_POST['username'] ?? 'admin';
|
||||||
$password = $_POST['password'] ?? 'admin';
|
$password = $_POST['password'] ?? 'admin';
|
||||||
|
|
||||||
@@ -63,6 +74,17 @@ class InstallController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checkPermissions() {
|
||||||
|
$dbDir = ROOT . '/app/Database';
|
||||||
|
$envFile = ROOT . '/.env';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'db_writable' => is_writable($dbDir),
|
||||||
|
'env_writable' => file_exists($envFile) ? is_writable($envFile) : is_writable(ROOT),
|
||||||
|
'root_writable' => is_writable(ROOT)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function isInstalled() {
|
private function isInstalled() {
|
||||||
// Check if .env exists and APP_KEY is set to something other than the default/example
|
// Check if .env exists and APP_KEY is set to something other than the default/example
|
||||||
$envPath = ROOT . '/.env';
|
$envPath = ROOT . '/.env';
|
||||||
|
|||||||
@@ -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', [
|
||||||
|
|||||||
@@ -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'
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,10 @@ class PublicStatusController extends Controller {
|
|||||||
// View: Show Search Page
|
// View: Show Search Page
|
||||||
public function index($session) {
|
public function index($session) {
|
||||||
// Just verify session existence to display Hotspot Name
|
// Just verify session existence to display Hotspot Name
|
||||||
|
// Session verified by RouterCheckMiddleware
|
||||||
$configModel = new Config();
|
$configModel = new Config();
|
||||||
$creds = $configModel->getSession($session);
|
$creds = $configModel->getSession($session);
|
||||||
|
|
||||||
if (!$creds) {
|
|
||||||
// If session invalid, maybe show 404 or generic error
|
|
||||||
echo "Session not found.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'hotspot_name' => $creds['hotspot_name'] ?? 'Hotspot',
|
'hotspot_name' => $creds['hotspot_name'] ?? 'Hotspot',
|
||||||
@@ -92,9 +87,6 @@ class PublicStatusController extends Controller {
|
|||||||
if (!empty($user)) {
|
if (!empty($user)) {
|
||||||
$u = $user[0];
|
$u = $user[0];
|
||||||
|
|
||||||
// DEBUG: Log the user data to see raw values
|
|
||||||
error_log("Status Debug: " . json_encode($u));
|
|
||||||
|
|
||||||
// --- SECURITY CHECK: Hide Unused Vouchers ---
|
// --- SECURITY CHECK: Hide Unused Vouchers ---
|
||||||
$uptimeRaw = $u['uptime'] ?? '0s';
|
$uptimeRaw = $u['uptime'] ?? '0s';
|
||||||
$bytesIn = intval($u['bytes-in'] ?? 0);
|
$bytesIn = intval($u['bytes-in'] ?? 0);
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ class QuickPrintController extends Controller {
|
|||||||
// Dashboard: List Cards
|
// Dashboard: List Cards
|
||||||
public function index($session) {
|
public function index($session) {
|
||||||
$qpModel = new QuickPrintModel();
|
$qpModel = new QuickPrintModel();
|
||||||
$packages = $qpModel->getAllBySession($session);
|
|
||||||
|
$configModel = new Config();
|
||||||
|
$creds = $configModel->getSession($session);
|
||||||
|
$routerId = $creds['id'] ?? null;
|
||||||
|
|
||||||
|
// If no ID (Legacy), fallback to empty list or handle gracefully.
|
||||||
|
// For now, we assume ID exists as per migration plan.
|
||||||
|
$packages = $routerId ? $qpModel->getAllByRouterId($routerId) : [];
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
@@ -32,11 +39,12 @@ class QuickPrintController extends Controller {
|
|||||||
// List/Manage Packages (CRUD)
|
// List/Manage Packages (CRUD)
|
||||||
public function manage($session) {
|
public function manage($session) {
|
||||||
$qpModel = new QuickPrintModel();
|
$qpModel = new QuickPrintModel();
|
||||||
$packages = $qpModel->getAllBySession($session);
|
|
||||||
|
|
||||||
// Need profiles for the Add/Edit Modal
|
|
||||||
$configModel = new Config();
|
$configModel = new Config();
|
||||||
$creds = $configModel->getSession($session);
|
$creds = $configModel->getSession($session);
|
||||||
|
$routerId = $creds['id'] ?? null;
|
||||||
|
|
||||||
|
$packages = $routerId ? $qpModel->getAllByRouterId($routerId) : [];
|
||||||
$profiles = [];
|
$profiles = [];
|
||||||
if ($creds) {
|
if ($creds) {
|
||||||
$API = new RouterOSAPI();
|
$API = new RouterOSAPI();
|
||||||
@@ -63,7 +71,13 @@ class QuickPrintController extends Controller {
|
|||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
|
|
||||||
$session = $_POST['session'] ?? '';
|
$session = $_POST['session'] ?? '';
|
||||||
|
|
||||||
|
$configModel = new Config();
|
||||||
|
$creds = $configModel->getSession($session);
|
||||||
|
$routerId = $creds['id'] ?? 0;
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
|
'router_id' => $routerId,
|
||||||
'session_name' => $session,
|
'session_name' => $session,
|
||||||
'name' => $_POST['name'] ?? 'Package',
|
'name' => $_POST['name'] ?? 'Package',
|
||||||
'server' => $_POST['server'] ?? 'all',
|
'server' => $_POST['server'] ?? 'all',
|
||||||
@@ -71,6 +85,7 @@ class QuickPrintController extends Controller {
|
|||||||
'prefix' => $_POST['prefix'] ?? '',
|
'prefix' => $_POST['prefix'] ?? '',
|
||||||
'char_length' => $_POST['char_length'] ?? 4,
|
'char_length' => $_POST['char_length'] ?? 4,
|
||||||
'price' => $_POST['price'] ?? 0,
|
'price' => $_POST['price'] ?? 0,
|
||||||
|
'selling_price' => $_POST['selling_price'] ?? ($_POST['price'] ?? 0),
|
||||||
'time_limit' => $_POST['time_limit'] ?? '',
|
'time_limit' => $_POST['time_limit'] ?? '',
|
||||||
'data_limit' => $_POST['data_limit'] ?? '',
|
'data_limit' => $_POST['data_limit'] ?? '',
|
||||||
'comment' => $_POST['comment'] ?? '',
|
'comment' => $_POST['comment'] ?? '',
|
||||||
@@ -85,6 +100,40 @@ class QuickPrintController extends Controller {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRUD: Update
|
||||||
|
public function update() {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
|
|
||||||
|
$session = $_POST['session'] ?? '';
|
||||||
|
$id = $_POST['id'] ?? '';
|
||||||
|
|
||||||
|
if (empty($id)) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'common.error', 'toasts.error_missing_id', [], true);
|
||||||
|
header("Location: /" . $session . "/quick-print/manage");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $_POST['name'] ?? 'Package',
|
||||||
|
'profile' => $_POST['profile'] ?? 'default',
|
||||||
|
'prefix' => $_POST['prefix'] ?? '',
|
||||||
|
'char_length' => $_POST['char_length'] ?? 4,
|
||||||
|
'price' => $_POST['price'] ?? 0,
|
||||||
|
'selling_price' => $_POST['selling_price'] ?? ($_POST['price'] ?? 0),
|
||||||
|
'time_limit' => $_POST['time_limit'] ?? '',
|
||||||
|
'data_limit' => $_POST['data_limit'] ?? '',
|
||||||
|
'comment' => $_POST['comment'] ?? '',
|
||||||
|
'color' => $_POST['color'] ?? 'bg-blue-500'
|
||||||
|
];
|
||||||
|
|
||||||
|
$qpModel = new QuickPrintModel();
|
||||||
|
$qpModel->update($id, $data); // Assuming update method exists in simple JSON model
|
||||||
|
|
||||||
|
\App\Helpers\FlashHelper::set('success', 'toasts.package_updated', 'toasts.package_updated_desc', [], true);
|
||||||
|
header("Location: /" . $session . "/quick-print/manage");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// CRUD: Delete
|
// CRUD: Delete
|
||||||
public function delete() {
|
public function delete() {
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
@@ -158,7 +207,9 @@ class QuickPrintController extends Controller {
|
|||||||
$API->comm("/ip/hotspot/user/add", $userData);
|
$API->comm("/ip/hotspot/user/add", $userData);
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
} else {
|
} else {
|
||||||
die("Connection failed");
|
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||||
|
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/quick-print/manage'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Helpers\FormatHelper;
|
|||||||
class SettingsController extends Controller {
|
class SettingsController extends Controller {
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
Middleware::auth();
|
// Auth handled by Router Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
public function system() {
|
public function system() {
|
||||||
@@ -33,10 +33,6 @@ class SettingsController extends Controller {
|
|||||||
return $this->view('settings/index', ['routers' => $routers]);
|
return $this->view('settings/index', ['routers' => $routers]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add() {
|
|
||||||
return $this->view('settings/form');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... (Existing Store methods) ...
|
// ... (Existing Store methods) ...
|
||||||
public function store() {
|
public function store() {
|
||||||
// Sanitize Session Name (Duplicate Frontend Logic)
|
// Sanitize Session Name (Duplicate Frontend Logic)
|
||||||
@@ -102,33 +98,7 @@ class SettingsController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function edit() {
|
|
||||||
// ID passed via query param or route param?
|
|
||||||
// Our router supports {id} but let's check how we handle it.
|
|
||||||
// Router: /settings/edit/{id}
|
|
||||||
// In Router.php, params are passed to method.
|
|
||||||
// So method signature should be edit($id)
|
|
||||||
|
|
||||||
// Wait, Router.php passes matches as params array to invokeCallback.
|
|
||||||
// So we need to capture arguments here.
|
|
||||||
$args = func_get_args();
|
|
||||||
$id = $args[0] ?? null;
|
|
||||||
|
|
||||||
if (!$id) {
|
|
||||||
header('Location: /settings/routers');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$configModel = new Config();
|
|
||||||
$session = $configModel->getSessionById($id);
|
|
||||||
|
|
||||||
if (!$session) {
|
|
||||||
header('Location: /settings/routers');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->view('settings/form', ['router' => $session]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update() {
|
public function update() {
|
||||||
$id = $_POST['id'];
|
$id = $_POST['id'];
|
||||||
@@ -316,7 +286,7 @@ class SettingsController extends Controller {
|
|||||||
// Restore Logos
|
// Restore Logos
|
||||||
if (isset($json['logos'])) {
|
if (isset($json['logos'])) {
|
||||||
$logoModel = new \App\Models\Logo();
|
$logoModel = new \App\Models\Logo();
|
||||||
$uploadDir = ROOT . '/public/assets/img/logos/';
|
$uploadDir = ROOT . '/public/uploads/logos/';
|
||||||
if (!file_exists($uploadDir)) {
|
if (!file_exists($uploadDir)) {
|
||||||
mkdir($uploadDir, 0777, true);
|
mkdir($uploadDir, 0777, true);
|
||||||
}
|
}
|
||||||
@@ -341,7 +311,7 @@ class SettingsController extends Controller {
|
|||||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, path=excluded.path, type=excluded.type, size=excluded.size", [
|
ON CONFLICT(id) DO UPDATE SET name=excluded.name, path=excluded.path, type=excluded.type, size=excluded.size", [
|
||||||
'id' => $logo['id'],
|
'id' => $logo['id'],
|
||||||
'name' => $logo['name'],
|
'name' => $logo['name'],
|
||||||
'path' => '/assets/img/logos/' . $filename,
|
'path' => '/uploads/logos/' . $filename,
|
||||||
'type' => $extension,
|
'type' => $extension,
|
||||||
'size' => $logo['size']
|
'size' => $logo['size']
|
||||||
]);
|
]);
|
||||||
@@ -371,22 +341,24 @@ class SettingsController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function uploadLogo() {
|
public function uploadLogo() {
|
||||||
if (!isset($_FILES['logo_file'])) {
|
if (!isset($_FILES['logo_file']) || $_FILES['logo_file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
|
||||||
header('Location: /settings/logos');
|
header('Location: /settings/logos');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$logoModel = new \App\Models\Logo();
|
$logoModel = new \App\Models\Logo();
|
||||||
try {
|
try {
|
||||||
$logoModel->add($_FILES['logo_file']);
|
$result = $logoModel->add($_FILES['logo_file']);
|
||||||
|
if ($result) {
|
||||||
|
\App\Helpers\FlashHelper::set('success', 'toasts.logo_uploaded', 'toasts.logo_uploaded_desc', [], true);
|
||||||
|
} else {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Generic upload error', [], true);
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Ideally flash error message to session
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', $e->getMessage(), [], true);
|
||||||
// For now, redirect (logging error via debug or ignoring as per simple req)
|
|
||||||
// session_start() is implicit in Middleware usually or index
|
|
||||||
// $_SESSION['error'] = $e->getMessage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
\App\Helpers\FlashHelper::set('success', 'toasts.logo_uploaded', 'toasts.logo_uploaded_desc', [], true);
|
|
||||||
header('Location: /settings/logos');
|
header('Location: /settings/logos');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ class Console {
|
|||||||
|
|
||||||
private function printBanner() {
|
private function printBanner() {
|
||||||
echo "\n";
|
echo "\n";
|
||||||
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.0" . self::COLOR_RESET . "\n\n";
|
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.1.0" . self::COLOR_RESET . "\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
private function commandServe($args) {
|
private function commandServe($args) {
|
||||||
|
|||||||
120
app/Core/Hooks.php
Normal file
120
app/Core/Hooks.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
78
app/Core/PluginManager.php
Normal file
78
app/Core/PluginManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,97 @@ namespace App\Core;
|
|||||||
|
|
||||||
class Router {
|
class Router {
|
||||||
protected $routes = [];
|
protected $routes = [];
|
||||||
|
protected $currentGroupMiddleware = [];
|
||||||
|
protected $lastRouteKey = null;
|
||||||
|
|
||||||
|
protected $middlewareAliases = [
|
||||||
|
'auth' => \App\Middleware\AuthMiddleware::class,
|
||||||
|
'cors' => \App\Middleware\CorsMiddleware::class,
|
||||||
|
'router.valid' => \App\Middleware\RouterCheckMiddleware::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a GET route
|
||||||
|
*/
|
||||||
public function get($path, $callback) {
|
public function get($path, $callback) {
|
||||||
$this->routes['GET'][$path] = $callback;
|
return $this->addRoute('GET', $path, $callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a POST route
|
||||||
|
*/
|
||||||
public function post($path, $callback) {
|
public function post($path, $callback) {
|
||||||
$this->routes['POST'][$path] = $callback;
|
return $this->addRoute('POST', $path, $callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add route to collection and return $this for chaining
|
||||||
|
*/
|
||||||
|
protected function addRoute($method, $path, $callback) {
|
||||||
|
$path = $this->normalizePath($path);
|
||||||
|
|
||||||
|
$this->routes[$method][$path] = [
|
||||||
|
'callback' => $callback,
|
||||||
|
'middleware' => $this->currentGroupMiddleware // Inherit group middleware
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->lastRouteKey = ['method' => $method, 'path' => $path];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach middleware to the last defined route
|
||||||
|
*/
|
||||||
|
public function middleware($names) {
|
||||||
|
if (!$this->lastRouteKey) return $this;
|
||||||
|
|
||||||
|
$method = $this->lastRouteKey['method'];
|
||||||
|
$path = $this->lastRouteKey['path'];
|
||||||
|
|
||||||
|
$middlewares = is_array($names) ? $names : [$names];
|
||||||
|
|
||||||
|
// Merge with existing middleware (from groups)
|
||||||
|
$this->routes[$method][$path]['middleware'] = array_merge(
|
||||||
|
$this->routes[$method][$path]['middleware'],
|
||||||
|
$middlewares
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a route group with shared attributes (middleware, prefix, etc.)
|
||||||
|
*/
|
||||||
|
public function group($attributes, callable $callback) {
|
||||||
|
$previousGroupMiddleware = $this->currentGroupMiddleware;
|
||||||
|
|
||||||
|
if (isset($attributes['middleware'])) {
|
||||||
|
$newMiddleware = is_array($attributes['middleware'])
|
||||||
|
? $attributes['middleware']
|
||||||
|
: [$attributes['middleware']];
|
||||||
|
|
||||||
|
$this->currentGroupMiddleware = array_merge(
|
||||||
|
$this->currentGroupMiddleware,
|
||||||
|
$newMiddleware
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the callback with $this router instance
|
||||||
|
$callback($this);
|
||||||
|
|
||||||
|
// Restore previous state
|
||||||
|
$this->currentGroupMiddleware = $previousGroupMiddleware;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizePath($path) {
|
||||||
|
return '/' . trim($path, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dispatch($uri, $method) {
|
public function dispatch($uri, $method) {
|
||||||
|
// 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
|
||||||
@@ -21,27 +102,24 @@ class Router {
|
|||||||
if (strpos($path, $scriptName) === 0) {
|
if (strpos($path, $scriptName) === 0) {
|
||||||
$path = substr($path, strlen($scriptName));
|
$path = substr($path, strlen($scriptName));
|
||||||
}
|
}
|
||||||
$path = '/' . trim($path, '/');
|
$path = $this->normalizePath($path);
|
||||||
|
|
||||||
// Global Install Check: Redirect if database is missing
|
// Global Install Check
|
||||||
$dbPath = ROOT . '/app/Database/database.sqlite';
|
$dbPath = ROOT . '/app/Database/database.sqlite';
|
||||||
if (!file_exists($dbPath)) {
|
if (!file_exists($dbPath)) {
|
||||||
// Whitelist /install route and assets to prevent infinite loop
|
|
||||||
if ($path !== '/install' && strpos($path, '/assets/') !== 0) {
|
if ($path !== '/install' && strpos($path, '/assets/') !== 0) {
|
||||||
header('Location: /install');
|
header('Location: /install');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check exact match first
|
// 1. Try Exact Match
|
||||||
if (isset($this->routes[$method][$path])) {
|
if (isset($this->routes[$method][$path])) {
|
||||||
$callback = $this->routes[$method][$path];
|
return $this->runRoute($this->routes[$method][$path], []);
|
||||||
return $this->invokeCallback($callback);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check dynamic routes
|
// 2. Try Dynamic Routes (Regex)
|
||||||
foreach ($this->routes[$method] as $route => $callback) {
|
foreach ($this->routes[$method] as $route => $config) {
|
||||||
// Convert route syntax to regex
|
|
||||||
// e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$#
|
// e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$#
|
||||||
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route);
|
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route);
|
||||||
$pattern = "#^" . $pattern . "$#";
|
$pattern = "#^" . $pattern . "$#";
|
||||||
@@ -49,13 +127,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
0
app/Database/.gitkeep
Normal 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ class TemplateHelper {
|
|||||||
|
|
||||||
$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>
|
||||||
@@ -77,6 +78,48 @@ class TemplateHelper {
|
|||||||
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;
|
||||||
const h = wrap.offsetHeight;
|
const h = wrap.offsetHeight;
|
||||||
|
|||||||
15
app/Middleware/AuthMiddleware.php
Normal file
15
app/Middleware/AuthMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Middleware/CorsMiddleware.php
Normal file
39
app/Middleware/CorsMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Middleware/MiddlewareInterface.php
Normal file
7
app/Middleware/MiddlewareInterface.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
interface MiddlewareInterface {
|
||||||
|
public function handle($request, \Closure $next);
|
||||||
|
}
|
||||||
41
app/Middleware/RouterCheckMiddleware.php
Normal file
41
app/Middleware/RouterCheckMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'] ?? '',
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
// Init
|
// Init
|
||||||
fetchInterfaces().then(() => {
|
fetchInterfaces().then(() => {
|
||||||
// Start Polling after interfaces loaded
|
// Start Polling after interfaces loaded
|
||||||
setInterval(fetchTraffic, 5000); // Every 5 seconds
|
const reloadInterval = <?= ($reload_interval ?? 5) * 1000 ?>; // Convert sec to ms
|
||||||
|
setInterval(fetchTraffic, reloadInterval);
|
||||||
fetchTraffic();
|
fetchTraffic();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -22,9 +22,9 @@ sort($uniqueModes);
|
|||||||
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
|
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
|
||||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/add" class="btn btn-primary">
|
<button onclick="openProfileModal('add')" class="btn btn-primary">
|
||||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_profiles.add_profile">Add Profile</span>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_profiles.add_profile">Add Profile</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,8 +79,21 @@ sort($uniqueModes);
|
|||||||
<tbody id="table-body">
|
<tbody id="table-body">
|
||||||
<?php if (!empty($profiles)): ?>
|
<?php if (!empty($profiles)): ?>
|
||||||
<?php foreach ($profiles as $profile): ?>
|
<?php foreach ($profiles as $profile): ?>
|
||||||
<tr class="table-row-item"
|
<tr class="table-row-item group-row"
|
||||||
data-name="<?= strtolower($profile['name'] ?? '') ?>"
|
data-id="<?= $profile['.id'] ?>"
|
||||||
|
data-name="<?= htmlspecialchars($profile['name'] ?? '') ?>"
|
||||||
|
data-shared-users="<?= htmlspecialchars($profile['shared-users'] ?? '1') ?>"
|
||||||
|
data-rate-limit="<?= htmlspecialchars($profile['rate-limit'] ?? '') ?>"
|
||||||
|
data-address-pool="<?= htmlspecialchars($profile['address-pool'] ?? 'none') ?>"
|
||||||
|
data-parent-queue="<?= htmlspecialchars($profile['parent-queue'] ?? 'none') ?>"
|
||||||
|
data-expired-mode="<?= htmlspecialchars($profile['meta']['expired_mode'] ?? 'none') ?>"
|
||||||
|
data-val-d="<?= htmlspecialchars($profile['val_d'] ?? '') ?>"
|
||||||
|
data-val-h="<?= htmlspecialchars($profile['val_h'] ?? '') ?>"
|
||||||
|
data-val-m="<?= htmlspecialchars($profile['val_m'] ?? '') ?>"
|
||||||
|
data-price="<?= htmlspecialchars($profile['meta']['price'] ?? '') ?>"
|
||||||
|
data-selling-price="<?= htmlspecialchars($profile['meta']['selling_price'] ?? '') ?>"
|
||||||
|
data-lock-user="<?= htmlspecialchars($profile['meta']['lock_user'] ?? 'Disable') ?>"
|
||||||
|
data-search-name="<?= strtolower($profile['name'] ?? '') ?>"
|
||||||
data-mode="<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>">
|
data-mode="<?= htmlspecialchars($profile['meta']['expired_mode_formatted'] ?? '') ?>">
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
@@ -89,9 +102,9 @@ sort($uniqueModes);
|
|||||||
<i data-lucide="ticket" class="w-4 h-4"></i>
|
<i data-lucide="ticket" class="w-4 h-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-medium text-foreground">
|
<div class="text-sm font-medium text-foreground">
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400">
|
<button onclick="openProfileModal('edit', this)" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400 text-left">
|
||||||
<?= htmlspecialchars($profile['name'] ?? '-') ?>
|
<?= htmlspecialchars($profile['name'] ?? '-') ?>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -129,9 +142,9 @@ sort($uniqueModes);
|
|||||||
|
|
||||||
<td class="text-right text-sm font-medium">
|
<td class="text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="btn bg-blue-50 hover:bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Edit">
|
<button onclick="openProfileModal('edit', this)" class="btn bg-blue-50 hover:bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Edit">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</a>
|
</button>
|
||||||
<form action="/<?= htmlspecialchars($session) ?>/hotspot/profile/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('hotspot_profiles.title') : 'Delete Profile?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete profile <?= $profile['name'] ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
<form action="/<?= htmlspecialchars($session) ?>/hotspot/profile/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('hotspot_profiles.title') : 'Delete Profile?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete profile <?= $profile['name'] ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||||
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
<input type="hidden" name="id" value="<?= $profile['.id'] ?>">
|
<input type="hidden" name="id" value="<?= $profile['.id'] ?>">
|
||||||
@@ -238,7 +251,7 @@ sort($uniqueModes);
|
|||||||
|
|
||||||
update() {
|
update() {
|
||||||
this.filteredRows = this.allRows.filter(row => {
|
this.filteredRows = this.allRows.filter(row => {
|
||||||
const name = row.dataset.name || '';
|
const name = row.dataset.searchName || '';
|
||||||
const mode = row.dataset.mode || '';
|
const mode = row.dataset.mode || '';
|
||||||
|
|
||||||
if (this.filters.search && !name.includes(this.filters.search)) return false;
|
if (this.filters.search && !name.includes(this.filters.search)) return false;
|
||||||
@@ -308,4 +321,200 @@ sort($uniqueModes);
|
|||||||
const rows = document.querySelectorAll('.table-row-item');
|
const rows = document.querySelectorAll('.table-row-item');
|
||||||
new TableManager(rows, 10);
|
new TableManager(rows, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openProfileModal(mode, btn = null) {
|
||||||
|
const template = document.getElementById('profile-form-template').innerHTML;
|
||||||
|
|
||||||
|
let title = window.i18n ? window.i18n.t('hotspot_profiles.form.add_title') : 'Add Profile';
|
||||||
|
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
title = window.i18n ? window.i18n.t('hotspot_profiles.form.edit_title') : 'Edit Profile';
|
||||||
|
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
|
||||||
|
}
|
||||||
|
|
||||||
|
const preConfirmFn = () => {
|
||||||
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
const form = popup.querySelector('form');
|
||||||
|
|
||||||
|
// Validity Toggle Logic for Modal
|
||||||
|
const modeSelect = form.querySelector('#expired-mode');
|
||||||
|
const validityGroup = form.querySelector('#validity-group');
|
||||||
|
|
||||||
|
function toggleValidity() {
|
||||||
|
if (!modeSelect || !validityGroup) return;
|
||||||
|
if (modeSelect.value === 'none') {
|
||||||
|
validityGroup.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
validityGroup.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeSelect) {
|
||||||
|
modeSelect.addEventListener('change', toggleValidity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
|
||||||
|
form.action = "/<?= htmlspecialchars($session) ?>/hotspot/profile/update";
|
||||||
|
|
||||||
|
// Populate Hidden ID
|
||||||
|
const idInput = form.querySelector('#form-id');
|
||||||
|
idInput.disabled = false;
|
||||||
|
idInput.value = row.dataset.id;
|
||||||
|
|
||||||
|
// Populate Fields
|
||||||
|
form.querySelector('[name="name"]').value = row.dataset.name || '';
|
||||||
|
form.querySelector('[name="shared-users"]').value = row.dataset.sharedUsers || '1';
|
||||||
|
form.querySelector('[name="rate-limit"]').value = row.dataset.rateLimit || '';
|
||||||
|
|
||||||
|
// Selects
|
||||||
|
if(form.querySelector('[name="address-pool"]')) form.querySelector('[name="address-pool"]').value = row.dataset.addressPool;
|
||||||
|
if(form.querySelector('[name="parent-queue"]')) form.querySelector('[name="parent-queue"]').value = row.dataset.parentQueue;
|
||||||
|
if(form.querySelector('[name="expired_mode"]')) form.querySelector('[name="expired_mode"]').value = row.dataset.expiredMode;
|
||||||
|
if(form.querySelector('[name="lock_user"]')) form.querySelector('[name="lock_user"]').value = row.dataset.lockUser;
|
||||||
|
|
||||||
|
// Validity
|
||||||
|
form.querySelector('[name="validity_d"]').value = row.dataset.valD || '';
|
||||||
|
form.querySelector('[name="validity_h"]').value = row.dataset.valH || '';
|
||||||
|
form.querySelector('[name="validity_m"]').value = row.dataset.valM || '';
|
||||||
|
|
||||||
|
// Prices
|
||||||
|
form.querySelector('[name="price"]').value = row.dataset.price || '';
|
||||||
|
form.querySelector('[name="selling_price"]').value = row.dataset.sellingPrice || '';
|
||||||
|
|
||||||
|
// Initial Toggle Check
|
||||||
|
toggleValidity();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template id="profile-form-template">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 text-left">
|
||||||
|
<!-- Form Column -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<form id="profile-form" action="/<?= htmlspecialchars($session) ?>/hotspot/profile/store" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
|
<input type="hidden" name="id" id="form-id" disabled>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="common.name">Name</label>
|
||||||
|
<input type="text" name="name" required class="w-full" data-i18n-placeholder="hotspot_profiles.form.name_placeholder" placeholder="e.g. 1Hour-Package">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pools & Shared Users -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.address_pool">Address Pool</label>
|
||||||
|
<select name="address-pool" class="w-full">
|
||||||
|
<option value="none" data-i18n="common.forms.none">none</option>
|
||||||
|
<?php foreach ($pools as $pool): ?>
|
||||||
|
<?php if(isset($pool['name'])): ?>
|
||||||
|
<option value="<?= htmlspecialchars($pool['name']) ?>"><?= htmlspecialchars($pool['name']) ?></option>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.shared_users">Shared Users</label>
|
||||||
|
<input type="number" name="shared-users" value="1" min="1" class="w-full" placeholder="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate Limit & Parent Queue -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.rate_limit">Rate Limit (Rx/Tx)</label>
|
||||||
|
<input type="text" name="rate-limit" class="w-full" data-i18n-placeholder="hotspot_profiles.form.rate_limit_help" placeholder="e.g. 512k/1M">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.parent_queue">Parent Queue</label>
|
||||||
|
<select name="parent-queue" class="w-full">
|
||||||
|
<option value="none" data-i18n="common.forms.none">none</option>
|
||||||
|
<?php foreach ($queues as $q): ?>
|
||||||
|
<option value="<?= htmlspecialchars($q) ?>"><?= htmlspecialchars($q) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expired Mode & Validity -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.expired_mode">Expired Mode</label>
|
||||||
|
<select name="expired_mode" id="expired-mode" class="w-full">
|
||||||
|
<option value="none" data-i18n="common.forms.none" selected>none</option>
|
||||||
|
<option value="rem">Remove</option>
|
||||||
|
<option value="ntf">Notice</option>
|
||||||
|
<option value="remc">Remove & Record</option>
|
||||||
|
<option value="ntfc">Notice & Record</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="validity-group" class="hidden space-y-1 transition-all">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.validity">Validity</label>
|
||||||
|
<div class="flex w-full">
|
||||||
|
<input type="number" name="validity_d" min="0" class="w-full text-center rounded-r-none border-r-0" placeholder="0D">
|
||||||
|
<input type="number" name="validity_h" min="0" class="w-full text-center rounded-none border-r-0" placeholder="0H">
|
||||||
|
<input type="number" name="validity_m" min="0" class="w-full text-center rounded-l-none" placeholder="0M">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prices -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.price">Price (Rp)</label>
|
||||||
|
<input type="number" name="price" class="w-full" placeholder="e.g. 5000">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.selling_price">Selling Price (Rp)</label>
|
||||||
|
<input type="number" name="selling_price" class="w-full" placeholder="e.g. 7000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lock User -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_profiles.form.lock_user">Lock User</label>
|
||||||
|
<select name="lock_user" class="w-full">
|
||||||
|
<option value="Disable" data-i18n="common.forms.disabled">Disable</option>
|
||||||
|
<option value="Enable" data-i18n="common.forms.enabled">Enable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-12"></div> <!-- Spacer for selects -->
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips Column -->
|
||||||
|
<div class="hidden lg:block space-y-4 border-l border-white/10 pl-6">
|
||||||
|
<h3 class="text-sm font-bold text-foreground flex items-center gap-2">
|
||||||
|
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
|
||||||
|
<span data-i18n="hotspot_profiles.form.quick_tips">Quick Tips</span>
|
||||||
|
</h3>
|
||||||
|
<ul class="text-xs text-accents-5 space-y-3 list-disc pl-4">
|
||||||
|
<li data-i18n="hotspot_profiles.form.tip_rate_limit">
|
||||||
|
<strong>Rate Limit</strong>: Rx/Tx (Upload/Download). Example: <code>512k/1M</code>
|
||||||
|
</li>
|
||||||
|
<li data-i18n="hotspot_profiles.form.tip_expired_mode">
|
||||||
|
<strong>Expired Mode</strong>: Select 'Remove' or 'Notice' to enable Validity.
|
||||||
|
</li>
|
||||||
|
<li data-i18n="hotspot_profiles.form.tip_parent_queue">
|
||||||
|
<strong>Parent Queue</strong>: Assigns users to a specific parent queue for bandwidth management.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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'; ?>
|
|
||||||
@@ -15,6 +15,10 @@ if (!empty($users)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort($uniqueProfiles);
|
sort($uniqueProfiles);
|
||||||
|
|
||||||
|
// $servers is passed from controller
|
||||||
|
if (!isset($servers)) $servers = [];
|
||||||
|
|
||||||
sort($uniqueComments);
|
sort($uniqueComments);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -27,9 +31,9 @@ sort($uniqueComments);
|
|||||||
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
|
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
|
||||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/add" class="btn btn-primary">
|
<button onclick="openUserModal('add')" class="btn btn-primary">
|
||||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_users.add_user">Add User</span>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="hotspot_users.add_user">Add User</span>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,13 +111,38 @@ sort($uniqueComments);
|
|||||||
<tbody id="table-body">
|
<tbody id="table-body">
|
||||||
<?php if (!empty($users)): ?>
|
<?php if (!empty($users)): ?>
|
||||||
<?php foreach ($users as $user): ?>
|
<?php foreach ($users as $user): ?>
|
||||||
|
<?php
|
||||||
|
// Helper to split time limit for editing (Simple parsing or raw passing)
|
||||||
|
// Assuming time limit format from router is like 1d2h3m or just 1h
|
||||||
|
// We will pass the raw string if we can't easily split, OR rely on a JS parser.
|
||||||
|
// For now let's pass raw limit-uptime.
|
||||||
|
|
||||||
|
// Just prepare some safe values
|
||||||
|
$id = $user['.id'];
|
||||||
|
$name = $user['name'] ?? '';
|
||||||
|
$profile = $user['profile'] ?? 'default';
|
||||||
|
$comment = $user['comment'] ?? '';
|
||||||
|
$server = $user['server'] ?? 'all';
|
||||||
|
$password = $user['password'] ?? '';
|
||||||
|
|
||||||
|
// Limits
|
||||||
|
$limitUptime = $user['limit-uptime'] ?? '';
|
||||||
|
$limitBytes = $user['limit-bytes-total'] ?? '';
|
||||||
|
?>
|
||||||
<tr class="table-row-item"
|
<tr class="table-row-item"
|
||||||
data-name="<?= strtolower($user['name'] ?? '') ?>"
|
data-id="<?= htmlspecialchars($id) ?>"
|
||||||
data-profile="<?= $user['profile'] ?? 'default' ?>"
|
data-name="<?= strtolower($name) ?>"
|
||||||
data-comment="<?= htmlspecialchars($user['comment'] ?? '') ?>">
|
data-rawname="<?= htmlspecialchars($name) ?>"
|
||||||
|
data-profile="<?= htmlspecialchars($profile) ?>"
|
||||||
|
data-comment="<?= htmlspecialchars($comment) ?>"
|
||||||
|
data-comment-raw="<?= htmlspecialchars($comment) ?>"
|
||||||
|
data-password="<?= htmlspecialchars($password) ?>"
|
||||||
|
data-server="<?= htmlspecialchars($server) ?>"
|
||||||
|
data-limit-uptime="<?= htmlspecialchars($limitUptime) ?>"
|
||||||
|
data-limit-bytes-total="<?= htmlspecialchars($limitBytes) ?>">
|
||||||
|
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<input type="checkbox" name="selected_users[]" value="<?= htmlspecialchars($user['.id']) ?>" class="user-checkbox checkbox">
|
<input type="checkbox" name="selected_users[]" value="<?= htmlspecialchars($id) ?>" class="user-checkbox checkbox">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center w-full">
|
<div class="flex items-center w-full">
|
||||||
@@ -122,19 +151,19 @@ sort($uniqueComments);
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($user['name'] ?? '-') ?></div>
|
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($name) ?></div>
|
||||||
<?php
|
<?php
|
||||||
$status = \App\Helpers\HotspotHelper::getUserStatus($user);
|
$status = \App\Helpers\HotspotHelper::getUserStatus($user);
|
||||||
echo \App\Helpers\ViewHelper::badge($status);
|
echo \App\Helpers\ViewHelper::badge($status);
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-accents-5"><?= htmlspecialchars($user['password'] ?? '******') ?></div>
|
<div class="text-xs text-accents-5"><?= htmlspecialchars($password) ?></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
<?= htmlspecialchars($user['profile'] ?? 'default') ?>
|
<?= htmlspecialchars($profile) ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -148,19 +177,19 @@ sort($uniqueComments);
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm text-accents-5 italic"><?= htmlspecialchars($user['comment'] ?? '-') ?></div>
|
<div class="text-sm text-accents-5 italic"><?= htmlspecialchars($comment) ?></div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right text-sm font-medium">
|
<td class="text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||||
<button onclick="printUser('<?= htmlspecialchars($user['.id']) ?>')" class="btn-icon" title="Print">
|
<button onclick="printUser('<?= htmlspecialchars($id) ?>')" class="btn-icon" title="Print">
|
||||||
<i data-lucide="printer" class="w-4 h-4"></i>
|
<i data-lucide="printer" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/user/edit/<?= urlencode($user['.id']) ?>" class="btn-icon inline-flex items-center justify-center" title="Edit">
|
<button onclick="openUserModal('edit', this)" class="btn-icon inline-flex items-center justify-center" title="Edit">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</a>
|
</button>
|
||||||
<form action="/<?= htmlspecialchars($session) ?>/hotspot/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Delete User?', 'Are you sure you want to delete user <?= htmlspecialchars($user['name'] ?? '') ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
<form action="/<?= htmlspecialchars($session) ?>/hotspot/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Delete User?', 'Are you sure you want to delete user <?= htmlspecialchars($name) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||||
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
<input type="hidden" name="id" value="<?= $user['.id'] ?>">
|
<input type="hidden" name="id" value="<?= $id ?>">
|
||||||
<button type="submit" class="btn-icon-danger" title="Delete">
|
<button type="submit" class="btn-icon-danger" title="Delete">
|
||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -188,6 +217,133 @@ sort($uniqueComments);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
|
<!-- Add/Edit User Template -->
|
||||||
|
<template id="user-form-template">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 text-left">
|
||||||
|
<!-- Form Column -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<form id="user-form" action="/<?= htmlspecialchars($session) ?>/hotspot/store" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
|
<input type="hidden" name="id" id="form-id" disabled> <!-- Disabled for Add -->
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Name & Password -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.username">Username</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-icon"><i data-lucide="user" class="w-4 h-4"></i></span>
|
||||||
|
<input type="text" name="name" required class="pl-10 w-full" data-i18n-placeholder="hotspot_users.form.username_placeholder" placeholder="e.g. voucher123">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.password">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-icon"><i data-lucide="key" class="w-4 h-4"></i></span>
|
||||||
|
<input type="text" name="password" required class="pl-10 w-full" data-i18n-placeholder="hotspot_users.form.password_placeholder" placeholder="e.g. 123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
|
<div class="space-y-1 col-span-1 md:col-span-2">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.profile">Profile</label>
|
||||||
|
<select name="profile" class="w-full" data-search="true">
|
||||||
|
<?php foreach($uniqueProfiles as $p): ?>
|
||||||
|
<option value="<?= htmlspecialchars($p) ?>"><?= htmlspecialchars($p) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.server">Server</label>
|
||||||
|
<select name="server" class="w-full">
|
||||||
|
<option value="all">all</option>
|
||||||
|
<?php
|
||||||
|
if (!empty($servers)):
|
||||||
|
foreach($servers as $s):
|
||||||
|
$sName = $s['name'] ?? '';
|
||||||
|
if ($sName === 'all' || empty($sName)) continue;
|
||||||
|
?>
|
||||||
|
<option value="<?= htmlspecialchars($sName) ?>"><?= htmlspecialchars($sName) ?></option>
|
||||||
|
<?php
|
||||||
|
endforeach;
|
||||||
|
endif;
|
||||||
|
?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.comment">Comment</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-icon"><i data-lucide="message-square" class="w-4 h-4"></i></span>
|
||||||
|
<input type="text" name="comment" class="pl-10 w-full" placeholder="Optional note">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Limit -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.time_limit">Time Limit</label>
|
||||||
|
<div class="flex w-full">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">D</span>
|
||||||
|
<input type="number" name="timelimit_d" min="0" class="w-full pr-6 rounded-r-none border-r-0 text-center" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">H</span>
|
||||||
|
<input type="number" name="timelimit_h" min="0" max="23" class="w-full pr-6 rounded-none border-r-0 text-center" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="absolute right-2 top-2 text-xs font-bold text-accents-4 pointer-events-none">M</span>
|
||||||
|
<input type="number" name="timelimit_m" min="0" max="59" class="w-full pr-6 rounded-l-none text-center" placeholder="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Limit -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="form-label" data-i18n="hotspot_users.form.data_limit">Data Limit</label>
|
||||||
|
<div class="flex relative w-full">
|
||||||
|
<div class="relative flex-grow z-0">
|
||||||
|
<span class="input-icon"><i data-lucide="database" class="w-4 h-4"></i></span>
|
||||||
|
<input type="number" name="datalimit_val" min="0" class="form-input w-full pl-10 rounded-r-none" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="relative -ml-px w-20 z-0">
|
||||||
|
<select name="datalimit_unit" class="w-full rounded-l-none bg-accents-1 text-center font-medium">
|
||||||
|
<option value="MB" selected>MB</option>
|
||||||
|
<option value="GB">GB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Spacer for dropdowns -->
|
||||||
|
<div class="h-24"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips Column -->
|
||||||
|
<div class="hidden lg:block space-y-4 border-l border-white/10 pl-6">
|
||||||
|
<h3 class="text-sm font-bold text-foreground flex items-center gap-2">
|
||||||
|
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-400"></i>
|
||||||
|
<span data-i18n="hotspot_users.form.quick_tips">Quick Tips</span>
|
||||||
|
</h3>
|
||||||
|
<ul class="text-xs text-accents-5 space-y-3 list-disc pl-4">
|
||||||
|
<li data-i18n="hotspot_users.form.tip_profiles">
|
||||||
|
<strong>Profiles</strong> define the default speed limits, session timeout, and shared users policy.
|
||||||
|
</li>
|
||||||
|
<li data-i18n="hotspot_users.form.tip_time_limit">
|
||||||
|
<strong>Time Limit</strong> is the total accumulated uptime allowed for this user.
|
||||||
|
</li>
|
||||||
|
<li data-i18n="hotspot_users.form.tip_data_limit">
|
||||||
|
<strong>Data Limit</strong> will override the profile's data limit settings if specified here. Set to 0 to use profile default.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
class TableManager {
|
class TableManager {
|
||||||
constructor(rows, itemsPerPage = 10) {
|
constructor(rows, itemsPerPage = 10) {
|
||||||
@@ -244,9 +400,7 @@ sort($uniqueComments);
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom Select Listener (Mutation Observer or custom event if we emitted one,
|
// Filters
|
||||||
// but for now relying on underlying SELECT change or custom-select class behavior)
|
|
||||||
// Since CustomSelect updates the original Select, we listen to change on original select
|
|
||||||
document.getElementById('filter-profile').addEventListener('change', (e) => {
|
document.getElementById('filter-profile').addEventListener('change', (e) => {
|
||||||
this.filters.profile = e.target.value;
|
this.filters.profile = e.target.value;
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
@@ -259,10 +413,7 @@ sort($uniqueComments);
|
|||||||
this.update();
|
this.update();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-bind actions when external CustomSelect updates the select value
|
// Listen for language change
|
||||||
// CustomSelect triggers 'change' event on original select, so standard listener works!
|
|
||||||
|
|
||||||
// Listen for language change to update pagination text
|
|
||||||
window.addEventListener('languageChanged', () => {
|
window.addEventListener('languageChanged', () => {
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
@@ -272,10 +423,10 @@ sort($uniqueComments);
|
|||||||
// Apply Filters
|
// Apply Filters
|
||||||
this.filteredRows = this.allRows.filter(row => {
|
this.filteredRows = this.allRows.filter(row => {
|
||||||
const name = row.dataset.name || '';
|
const name = row.dataset.name || '';
|
||||||
const comment = (row.dataset.comment || '').toLowerCase(); // dataset comment value
|
const comment = (row.dataset.comment || '').toLowerCase();
|
||||||
const profile = row.dataset.profile || '';
|
const profile = row.dataset.profile || '';
|
||||||
|
|
||||||
// 1. Search (Name or Comment)
|
// 1. Search
|
||||||
if (this.filters.search) {
|
if (this.filters.search) {
|
||||||
const matchName = name.includes(this.filters.search);
|
const matchName = name.includes(this.filters.search);
|
||||||
const matchComment = comment.includes(this.filters.search);
|
const matchComment = comment.includes(this.filters.search);
|
||||||
@@ -285,7 +436,7 @@ sort($uniqueComments);
|
|||||||
// 2. Profile
|
// 2. Profile
|
||||||
if (this.filters.profile && profile !== this.filters.profile) return false;
|
if (this.filters.profile && profile !== this.filters.profile) return false;
|
||||||
|
|
||||||
// 3. Comment (Exact match for dropdown)
|
// 3. Comment
|
||||||
if (this.filters.comment && row.dataset.comment !== this.filters.comment) return false;
|
if (this.filters.comment && row.dataset.comment !== this.filters.comment) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -303,7 +454,7 @@ sort($uniqueComments);
|
|||||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||||
const end = Math.min(start + this.itemsPerPage, total);
|
const end = Math.min(start + this.itemsPerPage, total);
|
||||||
|
|
||||||
// Update Text (Use Translation)
|
// Update Text
|
||||||
if (window.i18n) {
|
if (window.i18n) {
|
||||||
const text = window.i18n.t('common.table.showing', {
|
const text = window.i18n.t('common.table.showing', {
|
||||||
start: total === 0 ? 0 : start + 1,
|
start: total === 0 ? 0 : start + 1,
|
||||||
@@ -312,9 +463,9 @@ sort($uniqueComments);
|
|||||||
});
|
});
|
||||||
document.getElementById('pagination-text').textContent = text;
|
document.getElementById('pagination-text').textContent = text;
|
||||||
} else {
|
} else {
|
||||||
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
|
// Fallback
|
||||||
this.elements.endIdx.textContent = end;
|
const el = document.getElementById('pagination-text');
|
||||||
this.elements.totalCount.textContent = total;
|
el.innerHTML = `Showing <span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span> to <span class="font-medium text-foreground">${end}</span> of <span class="font-medium text-foreground">${total}</span> users`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear & Append Rows
|
// Clear & Append Rows
|
||||||
@@ -332,118 +483,177 @@ sort($uniqueComments);
|
|||||||
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
|
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-init Icons for new rows
|
// Re-init Icons
|
||||||
if (typeof lucide !== 'undefined') {
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Checkbox Logic (Select All should act on visible?)
|
// Reset "Select All"
|
||||||
// We usually reset "Select All" check when page changes
|
|
||||||
document.getElementById('select-all').checked = false;
|
document.getElementById('select-all').checked = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Modal Logic ---
|
||||||
|
function openUserModal(mode, btn = null) {
|
||||||
|
const template = document.getElementById('user-form-template').innerHTML;
|
||||||
|
|
||||||
|
let title = window.i18n ? window.i18n.t('hotspot_users.add_user') : 'Add User';
|
||||||
|
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
|
|
||||||
|
if (mode === 'edit') {
|
||||||
|
title = window.i18n ? window.i18n.t('hotspot_users.edit_user') : 'Edit User';
|
||||||
|
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
|
||||||
|
}
|
||||||
|
|
||||||
|
const preConfirmFn = () => {
|
||||||
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
const form = popup.querySelector('form');
|
||||||
|
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
|
||||||
|
form.action = "/<?= htmlspecialchars($session) ?>/hotspot/update";
|
||||||
|
|
||||||
|
// Populate Hidden ID
|
||||||
|
const idInput = form.querySelector('#form-id');
|
||||||
|
idInput.disabled = false;
|
||||||
|
idInput.value = row.dataset.id; // Ensure data-id is set on TR!
|
||||||
|
|
||||||
|
// Populate Fields (Assuming data attributes or simple values)
|
||||||
|
// NOTE: For full data (limits, etc), we might need to fetch OR put all in data attributes
|
||||||
|
// Let's rely on data attributes for speed, but need to add them to TR first
|
||||||
|
form.querySelector('[name="name"]').value = row.dataset.rawname || '';
|
||||||
|
form.querySelector('[name="password"]').value = row.dataset.password || '';
|
||||||
|
form.querySelector('[name="comment"]').value = row.dataset.commentRaw || '';
|
||||||
|
|
||||||
|
// Selects
|
||||||
|
const profileSel = form.querySelector('[name="profile"]');
|
||||||
|
if(profileSel) profileSel.value = row.dataset.profile;
|
||||||
|
|
||||||
|
const serverSel = form.querySelector('[name="server"]');
|
||||||
|
if(serverSel) serverSel.value = row.dataset.server || 'all';
|
||||||
|
|
||||||
|
// Limits (Parsing from data attributes)
|
||||||
|
// Time Limit
|
||||||
|
const tLimit = row.dataset.limitUptime || '';
|
||||||
|
// Simple regex parsing for 1d2h3m (Mikrotik format)
|
||||||
|
// This is complex to parse perfectly from string back to split fields without a helper
|
||||||
|
// For now, let's just leave 0 or try best effort if available
|
||||||
|
// Ideally, we pass split values in data attributes from PHP
|
||||||
|
if (row.dataset.timeD) form.querySelector('[name="timelimit_d"]').value = row.dataset.timeD;
|
||||||
|
if (row.dataset.timeH) form.querySelector('[name="timelimit_h"]').value = row.dataset.timeH;
|
||||||
|
if (row.dataset.timeM) form.querySelector('[name="timelimit_m"]').value = row.dataset.timeM;
|
||||||
|
|
||||||
|
// Data Limit
|
||||||
|
if (row.dataset.limitBytesTotal) {
|
||||||
|
const bytes = parseInt(row.dataset.limitBytesTotal);
|
||||||
|
if (bytes > 0) {
|
||||||
|
if (bytes >= 1073741824) { // GB
|
||||||
|
form.querySelector('[name="datalimit_val"]').value = (bytes / 1073741824).toFixed(0); // integer prefer
|
||||||
|
form.querySelector('[name="datalimit_unit"]').value = 'GB';
|
||||||
|
} else { // MB
|
||||||
|
form.querySelector('[name="datalimit_val"]').value = (bytes / 1048576).toFixed(0);
|
||||||
|
form.querySelector('[name="datalimit_unit"]').value = 'MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn, 'swal-wide');
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Init Custom Selects
|
// Init Checkboxes & Table methods
|
||||||
|
const selectAll = document.getElementById('select-all');
|
||||||
|
const toolbar = document.getElementById('batch-toolbar');
|
||||||
|
const countSpan = document.getElementById('selected-count');
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
|
||||||
|
// Init Custom Selects on Filter Bar
|
||||||
if (typeof CustomSelect !== 'undefined') {
|
if (typeof CustomSelect !== 'undefined') {
|
||||||
document.querySelectorAll('.custom-select').forEach(select => {
|
document.querySelectorAll('.custom-select.form-filter').forEach(s => new CustomSelect(s));
|
||||||
new CustomSelect(select);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init Table
|
// Init Table
|
||||||
const rows = document.querySelectorAll('.table-row-item');
|
const rows = document.querySelectorAll('.table-row-item');
|
||||||
const manager = new TableManager(rows, 10);
|
const manager = new TableManager(rows, 10);
|
||||||
|
|
||||||
// --- Toolbar Logic (Copied/Adapted) ---
|
// Toolbar Logic
|
||||||
const selectAll = document.getElementById('select-all');
|
|
||||||
const toolbar = document.getElementById('batch-toolbar');
|
|
||||||
const countSpan = document.getElementById('selected-count');
|
|
||||||
const tableBody = document.getElementById('table-body'); // Dynamic body
|
|
||||||
|
|
||||||
function updateToolbar() {
|
function updateToolbar() {
|
||||||
const checked = document.querySelectorAll('.user-checkbox:checked');
|
const checked = document.querySelectorAll('.user-checkbox:checked');
|
||||||
countSpan.textContent = checked.length;
|
countSpan.textContent = checked.length;
|
||||||
|
if (checked.length > 0) toolbar.classList.remove('translate-y-20', 'opacity-0');
|
||||||
if (checked.length > 0) {
|
else toolbar.classList.add('translate-y-20', 'opacity-0');
|
||||||
toolbar.classList.remove('translate-y-20', 'opacity-0');
|
|
||||||
} else {
|
|
||||||
toolbar.classList.add('translate-y-20', 'opacity-0');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(selectAll) {
|
||||||
selectAll.addEventListener('change', (e) => {
|
selectAll.addEventListener('change', (e) => {
|
||||||
const isChecked = e.target.checked;
|
const isChecked = e.target.checked;
|
||||||
// Only select visible rows on current page
|
// Only select visible rows
|
||||||
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
|
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
|
||||||
visibleCheckboxes.forEach(cb => cb.checked = isChecked);
|
visibleCheckboxes.forEach(cb => cb.checked = isChecked);
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Event Delegation for dynamic rows
|
if(tableBody) {
|
||||||
tableBody.addEventListener('change', (e) => {
|
tableBody.addEventListener('change', (e) => {
|
||||||
if (e.target.classList.contains('user-checkbox')) {
|
if (e.target.classList.contains('user-checkbox')) {
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
if (!e.target.checked) selectAll.checked = false;
|
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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -6,9 +6,26 @@
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
</div> <!-- /.container (Navbar Global) -->
|
</div> <!-- /.container (Navbar Global) -->
|
||||||
|
|
||||||
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200">
|
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200 py-8 text-center space-y-4">
|
||||||
<div class="max-w-7xl mx-auto px-4 py-6 text-center text-sm text-accents-5">
|
<!-- Links Row -->
|
||||||
<p><?= \App\Config\SiteConfig::getFooter() ?></p>
|
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
|
||||||
|
<a href="https://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/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||||
|
<span>Community</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/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; ?>
|
||||||
@@ -129,20 +146,67 @@
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Global Dropdown & Sidebar Logic
|
// Global Dropdown & Sidebar Logic
|
||||||
|
let menuTimeout;
|
||||||
|
|
||||||
function toggleMenu(menuId, button) {
|
function toggleMenu(menuId, button) {
|
||||||
|
if (menuTimeout) clearTimeout(menuTimeout);
|
||||||
|
|
||||||
const menu = document.getElementById(menuId);
|
const menu = document.getElementById(menuId);
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
// Handle Dropdowns (IDs start with 'lang-' or 'session-')
|
// Handle Dropdowns (IDs start with 'lang-', 'session-', or is 'notification-')
|
||||||
if (menuId.startsWith('lang-') || menuId === 'session-dropdown') {
|
if (menuId.startsWith('lang-') || menuId === 'session-dropdown' || menuId === 'notification-dropdown') {
|
||||||
if (menu.classList.contains('invisible')) {
|
const sidebarHeader = document.getElementById('sidebar-header');
|
||||||
|
const isOpening = menu.classList.contains('invisible');
|
||||||
|
|
||||||
|
if (isOpening) {
|
||||||
|
// Smart Positioning Logic
|
||||||
|
// 1. Reset to base state (remove specific overrides to measure natural/preferred state)
|
||||||
|
// But we want to preserve 'absolute' etc. The HTML has 'left-1/2 -translate-x-1/2' by default for sidebar.
|
||||||
|
// We'll calculate based on button rect and assumed menu width (w-48 = 12rem = 192px approx, or measure)
|
||||||
|
|
||||||
|
const btnRect = button.getBoundingClientRect();
|
||||||
|
const menuWidth = 192; // Approx w-48 standard. Better to measure if possible, but invisible elements have width.
|
||||||
|
// Actually, if we make it visible but opacity-0 first, we can measure.
|
||||||
|
// But simpler math:
|
||||||
|
const centerX = btnRect.left + (btnRect.width / 2);
|
||||||
|
const leftEdge = centerX - (menuWidth / 2);
|
||||||
|
const rightEdge = centerX + (menuWidth / 2);
|
||||||
|
|
||||||
|
// Remove conflicting positioning classes first to ensure a clean slate if we need to override
|
||||||
|
menu.classList.remove('left-0', 'right-0', 'left-1/2', '-translate-x-1/2', 'origin-top-left', 'origin-top-right', 'origin-top', 'left-3');
|
||||||
|
|
||||||
|
// Decision Tree
|
||||||
|
if (leftEdge < 10) {
|
||||||
|
// overflow left -> Align Left
|
||||||
|
menu.classList.add('left-0', 'origin-top-left');
|
||||||
|
} else if (rightEdge > window.innerWidth - 10) {
|
||||||
|
// overflow right -> Align Right
|
||||||
|
menu.classList.add('right-0', 'origin-top-right');
|
||||||
|
} else {
|
||||||
|
// Safe to Center
|
||||||
|
menu.classList.add('left-1/2', '-translate-x-1/2', 'origin-top');
|
||||||
|
}
|
||||||
|
|
||||||
// Open
|
// Open
|
||||||
menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||||
menu.classList.add('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
menu.classList.add('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||||
|
|
||||||
|
// Special Case: Sidebar Lang Dropdown needs overflow visible on header
|
||||||
|
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||||
|
sidebarHeader.classList.remove('overflow-hidden');
|
||||||
|
sidebarHeader.classList.add('overflow-visible');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Close
|
// Close
|
||||||
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
menu.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||||
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
menu.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||||
|
|
||||||
|
// Revert Overflow
|
||||||
|
if (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||||
|
sidebarHeader.classList.add('overflow-hidden');
|
||||||
|
sidebarHeader.classList.remove('overflow-visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,20 +239,22 @@
|
|||||||
|
|
||||||
// Close dropdowns when clicking outside
|
// Close dropdowns when clicking outside
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const dropdowns = document.querySelectorAll('[id^="lang-dropdown"], #session-dropdown');
|
const dropdowns = document.querySelectorAll('[id^="lang-dropdown"], #session-dropdown, #notification-dropdown');
|
||||||
dropdowns.forEach(dropdown => {
|
dropdowns.forEach(dropdown => {
|
||||||
if (!dropdown.classList.contains('invisible')) {
|
const sidebarHeader = document.getElementById('sidebar-header');
|
||||||
// Find the trigger button (previous sibling usually)
|
|
||||||
// Robust way: check if click is inside dropdown OR inside the button that toggles it
|
|
||||||
// Since button calls toggleMenu, we just need to ignore clicks inside dropdown and button?
|
|
||||||
// Actually, simpler: just check if click is OUTSIDE dropdown.
|
|
||||||
// But if click is on button, let button handler toggle it (don't double toggle).
|
|
||||||
|
|
||||||
|
if (!dropdown.classList.contains('invisible')) {
|
||||||
const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`);
|
const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`);
|
||||||
|
|
||||||
if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) {
|
if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) {
|
||||||
dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||||
dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||||
|
|
||||||
|
// Revert Sidebar Overflow if needed
|
||||||
|
if (dropdown.id === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||||
|
sidebarHeader.classList.add('overflow-hidden');
|
||||||
|
sidebarHeader.classList.remove('overflow-visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -209,18 +275,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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||||
|
<span>Community</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/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() ?>
|
<?= \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>
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -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 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>
|
</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.remove('dark');
|
|
||||||
localStorage.theme = 'light';
|
|
||||||
} else {
|
|
||||||
htmlElement.classList.add('dark');
|
htmlElement.classList.add('dark');
|
||||||
localStorage.theme = 'dark';
|
localStorage.theme = 'dark';
|
||||||
|
glider.style.transform = 'translateX(100%)';
|
||||||
|
// adjustment: logic depends on width.
|
||||||
|
// container is w-8+w-8+padding.
|
||||||
|
// 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>
|
||||||
|
|||||||
@@ -15,28 +15,46 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Navigation Links (Hidden on Mobile) -->
|
<!-- Desktop Navigation Links (Hidden on Mobile) -->
|
||||||
|
<?php if(isset($_SESSION['user_id'])): ?>
|
||||||
<div class="hidden md:flex items-center gap-6 text-sm font-medium">
|
<div class="hidden md:flex items-center gap-6 text-sm font-medium">
|
||||||
<a href="/" class="relative py-1 <?= ($uri == '/' || $uri == '/home') ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Home</a>
|
<a href="/" class="relative py-1 <?= ($uri == '/' || $uri == '/home') ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Home</a>
|
||||||
<a href="/settings" class="relative py-1 <?= (strpos($uri, '/settings') === 0) ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Settings</a>
|
<a href="/settings" class="relative py-1 <?= (strpos($uri, '/settings') === 0) ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side controls -->
|
<!-- Right side controls -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Desktop Control Pill (Hidden on Mobile) -->
|
<!-- Desktop Control Pill (Hidden on Mobile) -->
|
||||||
<div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform">
|
<div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform">
|
||||||
<!-- Language Switcher -->
|
<!-- Notification Bell -->
|
||||||
<div class="relative group">
|
<div class="relative group" onmouseleave="closeMenu('notification-dropdown')">
|
||||||
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-nav', this)" title="Change Language">
|
<button id="notification-bell" type="button" class="pill-lang-btn relative" onclick="toggleMenu('notification-dropdown', this)" title="Notifications">
|
||||||
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
|
<i data-lucide="bell" class="w-4 h-4"></i>
|
||||||
|
<span id="update-badge" class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full hidden animate-pulse"></span>
|
||||||
</button>
|
</button>
|
||||||
<div id="lang-dropdown-nav" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50">
|
<div id="notification-dropdown" class="absolute right-0 top-full mt-3 w-64 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge">
|
||||||
|
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="notifications.title">Notifications</div>
|
||||||
|
<div id="notification-content" class="p-4 text-sm text-accents-5 text-center" data-i18n="notifications.empty">
|
||||||
|
No new notifications
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pill-divider"></div>
|
||||||
|
|
||||||
|
<!-- Language Switcher -->
|
||||||
|
<div class="relative group" onmouseleave="closeMenu('lang-dropdown-desktop')">
|
||||||
|
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-desktop', this)" title="Change Language">
|
||||||
|
<i data-lucide="languages" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<div id="lang-dropdown-desktop" class="absolute right-0 top-full mt-3 w-48 bg-background/95 backdrop-blur-2xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50 dropdown-bridge">
|
||||||
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
<div class="px-3 py-2 text-[10px] font-bold text-accents-4 uppercase tracking-widest border-b border-accents-2/50 bg-accents-1/50" data-i18n="sidebar.switch_language">Select Language</div>
|
||||||
<?php
|
<?php
|
||||||
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
||||||
foreach ($languages as $lang):
|
foreach ($languages as $lang):
|
||||||
?>
|
?>
|
||||||
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang">
|
<button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang">
|
||||||
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
|
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
|
||||||
<span><?= $lang['name'] ?></span>
|
<span><?= $lang['name'] ?></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -44,8 +62,6 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pill-divider"></div>
|
|
||||||
|
|
||||||
<!-- Theme Toggle (Segmented) -->
|
<!-- Theme Toggle (Segmented) -->
|
||||||
<div class="segmented-switch theme-toggle" title="Toggle Theme">
|
<div class="segmented-switch theme-toggle" title="Toggle Theme">
|
||||||
<div class="segmented-switch-slider"></div>
|
<div class="segmented-switch-slider"></div>
|
||||||
@@ -88,6 +104,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
<div id="mobile-navbar-menu" class="md:hidden border-t border-accents-2 bg-background/95 backdrop-blur-xl transition-all duration-300 ease-in-out max-h-0 opacity-0 invisible overflow-hidden">
|
<div id="mobile-navbar-menu" class="md:hidden border-t border-accents-2 bg-background/95 backdrop-blur-xl transition-all duration-300 ease-in-out max-h-0 opacity-0 invisible overflow-hidden">
|
||||||
<div class="px-4 pt-4 pb-6 space-y-4">
|
<div class="px-4 pt-4 pb-6 space-y-4">
|
||||||
<!-- Nav Links -->
|
<!-- Nav Links -->
|
||||||
|
<?php if(isset($_SESSION['user_id'])): ?>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= ($uri == '/' || $uri == '/home') ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>">
|
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= ($uri == '/' || $uri == '/home') ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>">
|
||||||
<i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
|
<i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
|
||||||
@@ -98,6 +115,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Mobile Controls Overlay -->
|
<!-- Mobile Controls Overlay -->
|
||||||
<div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4">
|
<div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4">
|
||||||
|
|||||||
@@ -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/issues" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||||
|
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||||
|
<span data-i18n="sidebar.community">Community</span>
|
||||||
|
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Repo -->
|
||||||
|
<a href="https://github.com/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();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function isActive($path, $current) {
|
|||||||
$menu = [
|
$menu = [
|
||||||
['label' => 'routers_title', 'url' => '/settings', 'namespace' => 'settings'],
|
['label' => 'routers_title', 'url' => '/settings', 'namespace' => 'settings'],
|
||||||
['label' => 'system', 'url' => '/settings/system', 'namespace' => 'settings'],
|
['label' => 'system', 'url' => '/settings/system', 'namespace' => 'settings'],
|
||||||
['label' => 'templates_title', 'url' => '/settings/templates', 'namespace' => 'settings'],
|
['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'],
|
||||||
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
|
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
|
||||||
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
|
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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: {
|
|
||||||
popup: 'swal2-premium-card',
|
|
||||||
confirmButton: 'btn btn-primary',
|
|
||||||
},
|
|
||||||
buttonsStyling: false,
|
|
||||||
didClose: () => {
|
didClose: () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.getElementById('voucher-code');
|
const el = document.getElementById('voucher-code');
|
||||||
if(el) { el.focus(); el.select(); }
|
if(el) { el.focus(); el.select(); }
|
||||||
}, 100);
|
}, 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',
|
||||||
|
|||||||
@@ -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,29 +111,23 @@ 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">
|
|
||||||
<h3 class="text-lg font-bold text-foreground" id="modal-title" data-i18n="quick_print.add_package">Add Package</h3>
|
|
||||||
<button onclick="closeModal()" class="text-accents-5 hover:text-foreground">
|
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="p-6 space-y-4">
|
|
||||||
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||||
|
<!-- Hidden ID for Edit Mode (will be disabled/removed for Add) -->
|
||||||
|
<input type="hidden" name="id" id="form-id" disabled>
|
||||||
|
|
||||||
<!-- Quick Inputs Grid -->
|
<!-- Quick Inputs Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="col-span-1 md:col-span-2">
|
<div class="col-span-1 md:col-span-2">
|
||||||
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label>
|
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label>
|
||||||
<input type="text" name="name" required class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary placeholder:text-accents-3" placeholder="e.g. 3 Hours Voucher">
|
<input type="text" name="name" required class="w-full" placeholder="e.g. 3 Hours Voucher">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
|
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
|
||||||
<select name="profile" class="custom-select w-full" data-search="true">
|
<select name="profile" class="w-full" data-search="true">
|
||||||
<?php foreach($profiles as $p): ?>
|
<?php foreach($profiles as $p): ?>
|
||||||
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -132,7 +136,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
|
<label class="form-label" data-i18n="quick_print.card_color">Card Color</label>
|
||||||
<select name="color" class="custom-select w-full">
|
<select name="color" class="w-full">
|
||||||
<option value="bg-blue-500" data-i18n="colors.blue">Blue</option>
|
<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-red-500" data-i18n="colors.red">Red</option>
|
||||||
<option value="bg-green-500" data-i18n="colors.green">Green</option>
|
<option value="bg-green-500" data-i18n="colors.green">Green</option>
|
||||||
@@ -146,22 +150,22 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.price">Price (Rp)</label>
|
<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">
|
<input type="number" name="price" class="w-full" placeholder="5000">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.selling_price">Selling Price</label>
|
<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">
|
<input type="number" name="selling_price" class="w-full" placeholder="Default same">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.prefix">Prefix</label>
|
<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-">
|
<input type="text" name="prefix" class="w-full" placeholder="Example: VIP-">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
|
<label class="form-label" data-i18n="quick_print.char_length">Char Length</label>
|
||||||
<select name="char_length" class="custom-select w-full">
|
<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="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="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>
|
<option value="8" data-i18n="common.char_length" data-i18n-params='{"n": 8}'>8 Characters</option>
|
||||||
@@ -170,27 +174,21 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.time_limit">Time Limit</label>
|
<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">
|
<input type="text" name="time_limit" class="w-full" placeholder="3h">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" data-i18n="quick_print.data_limit">Data Limit</label>
|
<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)">
|
<input type="text" name="data_limit" class="w-full" placeholder="500M (Optional)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-1 md:col-span-2">
|
<div class="col-span-1 md:col-span-2">
|
||||||
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
|
<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">
|
<input type="text" name="comment" class="w-full" placeholder="Description or Note">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-accents-2 mt-4">
|
|
||||||
<button type="button" onclick="closeModal()" class="btn btn-secondary" data-i18n="common.cancel">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary" data-i18n="quick_print.save_package">Save Package</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
class TableManager {
|
class TableManager {
|
||||||
@@ -298,27 +296,63 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlay = document.getElementById('modal-overlay');
|
function openModal(mode, btn = null) {
|
||||||
const content = document.getElementById('modal-content');
|
const template = document.getElementById('package-form-template').innerHTML;
|
||||||
|
|
||||||
function openModal(mode) {
|
let title = window.i18n ? window.i18n.t('quick_print.add_package') : 'Add Package';
|
||||||
overlay.classList.remove('hidden');
|
let saveBtn = window.i18n ? window.i18n.t('quick_print.save_package') : 'Save Package';
|
||||||
// Trigger reflow
|
|
||||||
void overlay.offsetWidth;
|
|
||||||
|
|
||||||
overlay.classList.remove('opacity-0');
|
// Validation Callback
|
||||||
content.classList.add('open');
|
const preConfirmFn = () => {
|
||||||
|
const form = Swal.getHtmlContainer().querySelector('form');
|
||||||
|
if(form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
// Population Callback (Runs BEFORE CustomSelect init)
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
const form = popup.querySelector('form');
|
||||||
|
|
||||||
|
// Update Route Logic Here if needed, or rely on Hidden ID
|
||||||
|
// For now backend handles update if ID is present
|
||||||
|
form.action = "/<?= htmlspecialchars($session) ?>/quick-print/update";
|
||||||
|
|
||||||
|
// Populate inputs
|
||||||
|
form.querySelector('[name="id"]').value = row.dataset.id;
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
// Pass callbacks to helper
|
||||||
overlay.classList.add('opacity-0');
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
|
||||||
content.classList.remove('open');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<!-- Spacer -->
|
<!-- Spacer -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 w-full md:w-auto">
|
<div class="flex gap-2 w-full md:w-auto">
|
||||||
<button onclick="openModal('addModal')" class="btn btn-primary w-full md:w-auto">
|
<button onclick="openCorsModal()" class="btn btn-primary w-full md:w-auto">
|
||||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.add_rule">Add CORS Rule</span>
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="settings.add_rule">Add CORS Rule</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,12 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<tbody id="table-body">
|
<tbody id="table-body">
|
||||||
<?php if (!empty($rules)): ?>
|
<?php if (!empty($rules)): ?>
|
||||||
<?php foreach ($rules as $rule): ?>
|
<?php foreach ($rules as $rule): ?>
|
||||||
<tr class="table-row-item">
|
<tr class="table-row-item"
|
||||||
|
data-rule-id="<?= $rule['id'] ?>"
|
||||||
|
data-origin="<?= htmlspecialchars($rule['origin']) ?>"
|
||||||
|
data-headers="<?= htmlspecialchars(implode(', ', $rule['headers_arr'])) ?>"
|
||||||
|
data-max-age="<?= $rule['max_age'] ?>"
|
||||||
|
data-methods='<?= json_encode($rule['methods_arr']) ?>'>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($rule['origin']) ?></div>
|
<div class="text-sm font-medium text-foreground"><?= htmlspecialchars($rule['origin']) ?></div>
|
||||||
<div class="text-xs text-accents-4">Max Age: <?= $rule['max_age'] ?>s</div>
|
<div class="text-xs text-accents-4">Max Age: <?= $rule['max_age'] ?>s</div>
|
||||||
@@ -57,7 +62,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right text-sm font-medium">
|
<td class="text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||||
<button onclick="editRule(<?= htmlspecialchars(json_encode($rule)) ?>)" class="btn-icon" title="Edit">
|
<button onclick="openCorsModal(this.closest('tr'))" class="btn-icon" title="Edit">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
<form action="/settings/api-cors/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.cors_rule_deleted') : 'Delete CORS Rule?', 'Are you sure you want to delete the CORS rule for <?= htmlspecialchars($rule['origin']) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
<form action="/settings/api-cors/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.cors_rule_deleted') : 'Delete CORS Rule?', 'Are you sure you want to delete the CORS rule for <?= htmlspecialchars($rule['origin']) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||||
@@ -85,136 +90,71 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Modal -->
|
|
||||||
<div id="addModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
|
||||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('addModal')"></div>
|
|
||||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
|
||||||
<div class="card shadow-2xl">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h3 class="text-lg font-bold" data-i18n="settings.add_rule">Add CORS Rule</h3>
|
|
||||||
<button onclick="closeModal('addModal')" class="text-accents-5 hover:text-foreground">
|
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form action="/settings/api-cors/store" method="POST" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
|
|
||||||
<input type="text" name="origin" class="form-control" placeholder="https://example.com or *" required>
|
|
||||||
<p class="text-xs text-orange-500 dark:text-orange-400 mt-1 font-medium">Use * for all origins (not recommended for production).</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
|
||||||
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
|
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox" <?= in_array($m, ['GET', 'POST']) ? 'checked' : '' ?>>
|
|
||||||
<span class="text-sm"><?= $m ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
|
|
||||||
<input type="text" name="headers" class="form-control" value="*" placeholder="Content-Type, Authorization, *">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
|
|
||||||
<input type="number" name="max_age" class="form-control" value="3600">
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end pt-4">
|
|
||||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit Modal -->
|
|
||||||
<div id="editModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
|
||||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('editModal')"></div>
|
|
||||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
|
||||||
<div class="card shadow-2xl">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h3 class="text-lg font-bold" data-i18n="settings.edit_rule">Edit CORS Rule</h3>
|
|
||||||
<button onclick="closeModal('editModal')" class="text-accents-5 hover:text-foreground">
|
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form action="/settings/api-cors/update" method="POST" class="space-y-4">
|
|
||||||
<input type="hidden" name="id" id="edit_id">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
|
|
||||||
<input type="text" name="origin" id="edit_origin" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
|
|
||||||
<div class="grid grid-cols-3 gap-2" id="edit_methods_container">
|
|
||||||
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
|
|
||||||
<label class="flex items-center space-x-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox edit-method-check" data-method="<?= $m ?>">
|
|
||||||
<span class="text-sm"><?= $m ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
|
|
||||||
<input type="text" name="headers" id="edit_headers" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
|
|
||||||
<input type="number" name="max_age" id="edit_max_age" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end pt-4">
|
|
||||||
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save Changes</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function openModal(id) {
|
async function openCorsModal(row = null) {
|
||||||
const modal = document.getElementById(id);
|
const isEdit = !!row;
|
||||||
const content = modal.querySelector('.modal-content');
|
const title = isEdit ? (window.i18n ? window.i18n.t('settings.edit_rule') : 'Edit CORS Rule') : (window.i18n ? window.i18n.t('settings.add_rule') : 'Add CORS Rule');
|
||||||
modal.classList.remove('hidden');
|
const template = document.getElementById('cors-form-template').innerHTML;
|
||||||
|
const saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||||
|
|
||||||
// Use double requestAnimationFrame to ensure the browser has painted the hidden->block change
|
const preConfirmFn = () => {
|
||||||
// before we trigger the opacity/transform transitions.
|
const form = document.getElementById('cors-form');
|
||||||
requestAnimationFrame(() => {
|
if (!form.checkValidity()) {
|
||||||
requestAnimationFrame(() => {
|
form.reportValidity();
|
||||||
modal.classList.remove('opacity-0');
|
return false;
|
||||||
content.classList.remove('scale-95', 'opacity-0');
|
}
|
||||||
content.classList.add('scale-100', 'opacity-100');
|
form.submit();
|
||||||
});
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenedFn = (popup) => {
|
||||||
|
const form = popup.querySelector('#cors-form');
|
||||||
|
if (isEdit) {
|
||||||
|
form.action = '/settings/api-cors/update';
|
||||||
|
form.querySelector('[name="id"]').value = row.dataset.ruleId;
|
||||||
|
form.querySelector('[name="origin"]').value = row.dataset.origin;
|
||||||
|
form.querySelector('[name="headers"]').value = row.dataset.headers;
|
||||||
|
form.querySelector('[name="max_age"]').value = row.dataset.maxAge;
|
||||||
|
|
||||||
|
const methods = JSON.parse(row.dataset.methods || '[]');
|
||||||
|
form.querySelectorAll('[name="methods[]"]').forEach(cb => {
|
||||||
|
cb.checked = methods.includes(cb.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function closeModal(id) {
|
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
|
||||||
const modal = document.getElementById(id);
|
|
||||||
const content = modal.querySelector('.modal-content');
|
|
||||||
modal.classList.add('opacity-0');
|
|
||||||
content.classList.remove('scale-100', 'opacity-100');
|
|
||||||
content.classList.add('scale-95', 'opacity-0');
|
|
||||||
setTimeout(() => { modal.classList.add('hidden'); }, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function editRule(rule) {
|
|
||||||
document.getElementById('edit_id').value = rule.id;
|
|
||||||
document.getElementById('edit_origin').value = rule.origin;
|
|
||||||
document.getElementById('edit_headers').value = rule.headers_arr.join(', ');
|
|
||||||
document.getElementById('edit_max_age').value = rule.max_age;
|
|
||||||
|
|
||||||
// Clear and check checkboxes
|
|
||||||
const methods = rule.methods_arr;
|
|
||||||
document.querySelectorAll('.edit-method-check').forEach(cb => {
|
|
||||||
cb.checked = methods.includes(cb.dataset.method);
|
|
||||||
});
|
|
||||||
|
|
||||||
openModal('editModal');
|
|
||||||
}
|
}
|
||||||
</script>
|
</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'; ?>
|
||||||
|
|||||||
@@ -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'; ?>
|
|
||||||
@@ -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'; ?>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@@ -104,6 +114,18 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<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><div class="container"></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">
|
||||||
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
<div class="flex items-center justify-between p-2 rounded bg-accents-1 border border-accents-2">
|
||||||
@@ -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
|
function initEditor() {
|
||||||
historyStack.push({ value: editor.value, selectionStart: 0, selectionEnd: 0 });
|
if (typeof MivoEditor === 'undefined') {
|
||||||
|
console.error('CodeMirror bundle not loaded yet.');
|
||||||
function saveState() {
|
|
||||||
// Limit stack size
|
|
||||||
if (historyStack.length > 50) historyStack.shift();
|
|
||||||
|
|
||||||
const lastState = historyStack[historyStack.length - 1];
|
|
||||||
if (lastState && lastState.value === editor.value) return; // No change
|
|
||||||
|
|
||||||
historyStack.push({
|
|
||||||
value: editor.value,
|
|
||||||
selectionStart: editor.selectionStart,
|
|
||||||
selectionEnd: editor.selectionEnd
|
|
||||||
});
|
|
||||||
redoStack = []; // Clear redo on new change
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounced save for typing
|
|
||||||
editor.addEventListener('input', (e) => {
|
|
||||||
if (!isTyping) {
|
|
||||||
// Save state *before* a burst of typing starts?
|
|
||||||
// Actually usually we save *after*.
|
|
||||||
// For robust undo: save state Before modification if possible, or assume previous state is safe.
|
|
||||||
// Simplified: Save debounced.
|
|
||||||
clearTimeout(typingTimer);
|
|
||||||
typingTimer = setTimeout(saveState, 500);
|
|
||||||
}
|
|
||||||
updatePreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Keyboard Shortcuts (Undo/Redo, Tab, Enter) ---
|
|
||||||
editor.addEventListener('keydown', function(e) {
|
|
||||||
// Undo: Ctrl+Z
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
undo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Redo: Ctrl+Y or Ctrl+Shift+Z
|
|
||||||
if (((e.ctrlKey || e.metaKey) && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
redo();
|
|
||||||
return;
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter: Auto-indent checking previous line
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const start = this.selectionStart;
|
|
||||||
const end = this.selectionEnd;
|
|
||||||
const val = this.value;
|
|
||||||
|
|
||||||
// Find start of current line
|
|
||||||
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
|
||||||
const currentLine = val.substring(lineStart, start);
|
|
||||||
|
|
||||||
// Calculate indentation
|
|
||||||
const match = currentLine.match(/^\s*/);
|
|
||||||
const indent = match ? match[0] : '';
|
|
||||||
|
|
||||||
const insert = '\n' + indent;
|
|
||||||
|
|
||||||
this.value = val.substring(0, start) + insert + val.substring(end);
|
|
||||||
this.selectionStart = this.selectionEnd = start + insert.length;
|
|
||||||
|
|
||||||
saveState(); // Immediate save on Enter
|
|
||||||
updatePreview();
|
updatePreview();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function undo() {
|
// Set focus
|
||||||
if (historyStack.length > 1) { // Keep initial state
|
cmView.focus();
|
||||||
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
|
||||||
@@ -368,7 +285,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
};
|
};
|
||||||
|
|
||||||
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
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initEditor();
|
||||||
updatePreview();
|
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'; ?>
|
||||||
@@ -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">
|
||||||
@@ -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');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
}, 300); // Match duration-300
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
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'];
|
|
||||||
document.getElementById('edit_start_date').value = task['start-date'];
|
|
||||||
document.getElementById('edit_start_time').value = task['start-time'];
|
|
||||||
document.getElementById('edit_on_event').value = task['on-event'];
|
|
||||||
document.getElementById('edit_comment').value = task['comment'] ?? '';
|
|
||||||
|
|
||||||
openModal('editModal');
|
if (mode === 'edit' && btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
form.action = "/<?= htmlspecialchars($session) ?>/system/scheduler/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="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'; ?>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
10
docker/aapanel-env.example
Normal file
10
docker/aapanel-env.example
Normal 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
|
||||||
36
docker/aapanel-template.yml
Normal file
36
docker/aapanel-template.yml
Normal 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
|
||||||
61
docs/.vitepress/cache/deps/_metadata.json
vendored
61
docs/.vitepress/cache/deps/_metadata.json
vendored
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9719
docs/.vitepress/cache/deps/chunk-2CLQ7TTZ.js
vendored
9719
docs/.vitepress/cache/deps/chunk-2CLQ7TTZ.js
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
12824
docs/.vitepress/cache/deps/chunk-LE5NDSFD.js
vendored
12824
docs/.vitepress/cache/deps/chunk-LE5NDSFD.js
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
10
docs/.vitepress/cache/deps/chunk-PZ5AY32C.js
vendored
10
docs/.vitepress/cache/deps/chunk-PZ5AY32C.js
vendored
@@ -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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"sources": [],
|
|
||||||
"sourcesContent": [],
|
|
||||||
"mappings": "",
|
|
||||||
"names": []
|
|
||||||
}
|
|
||||||
39046
docs/.vitepress/cache/deps/lucide-vue-next.js
vendored
39046
docs/.vitepress/cache/deps/lucide-vue-next.js
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
3
docs/.vitepress/cache/deps/package.json
vendored
3
docs/.vitepress/cache/deps/package.json
vendored
@@ -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
@@ -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
|
|
||||||
@@ -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
1815
docs/.vitepress/cache/deps/vitepress___minisearch.js
vendored
1815
docs/.vitepress/cache/deps/vitepress___minisearch.js
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
348
docs/.vitepress/cache/deps/vue.js
vendored
348
docs/.vitepress/cache/deps/vue.js
vendored
@@ -1,348 +0,0 @@
|
|||||||
import {
|
|
||||||
BaseTransition,
|
|
||||||
BaseTransitionPropsValidators,
|
|
||||||
Comment,
|
|
||||||
DeprecationTypes,
|
|
||||||
EffectScope,
|
|
||||||
ErrorCodes,
|
|
||||||
ErrorTypeStrings,
|
|
||||||
Fragment,
|
|
||||||
KeepAlive,
|
|
||||||
ReactiveEffect,
|
|
||||||
Static,
|
|
||||||
Suspense,
|
|
||||||
Teleport,
|
|
||||||
Text,
|
|
||||||
TrackOpTypes,
|
|
||||||
Transition,
|
|
||||||
TransitionGroup,
|
|
||||||
TriggerOpTypes,
|
|
||||||
VueElement,
|
|
||||||
assertNumber,
|
|
||||||
callWithAsyncErrorHandling,
|
|
||||||
callWithErrorHandling,
|
|
||||||
camelize,
|
|
||||||
capitalize,
|
|
||||||
cloneVNode,
|
|
||||||
compatUtils,
|
|
||||||
compile,
|
|
||||||
computed,
|
|
||||||
createApp,
|
|
||||||
createBaseVNode,
|
|
||||||
createBlock,
|
|
||||||
createCommentVNode,
|
|
||||||
createElementBlock,
|
|
||||||
createHydrationRenderer,
|
|
||||||
createPropsRestProxy,
|
|
||||||
createRenderer,
|
|
||||||
createSSRApp,
|
|
||||||
createSlots,
|
|
||||||
createStaticVNode,
|
|
||||||
createTextVNode,
|
|
||||||
createVNode,
|
|
||||||
customRef,
|
|
||||||
defineAsyncComponent,
|
|
||||||
defineComponent,
|
|
||||||
defineCustomElement,
|
|
||||||
defineEmits,
|
|
||||||
defineExpose,
|
|
||||||
defineModel,
|
|
||||||
defineOptions,
|
|
||||||
defineProps,
|
|
||||||
defineSSRCustomElement,
|
|
||||||
defineSlots,
|
|
||||||
devtools,
|
|
||||||
effect,
|
|
||||||
effectScope,
|
|
||||||
getCurrentInstance,
|
|
||||||
getCurrentScope,
|
|
||||||
getCurrentWatcher,
|
|
||||||
getTransitionRawChildren,
|
|
||||||
guardReactiveProps,
|
|
||||||
h,
|
|
||||||
handleError,
|
|
||||||
hasInjectionContext,
|
|
||||||
hydrate,
|
|
||||||
hydrateOnIdle,
|
|
||||||
hydrateOnInteraction,
|
|
||||||
hydrateOnMediaQuery,
|
|
||||||
hydrateOnVisible,
|
|
||||||
initCustomFormatter,
|
|
||||||
initDirectivesForSSR,
|
|
||||||
inject,
|
|
||||||
isMemoSame,
|
|
||||||
isProxy,
|
|
||||||
isReactive,
|
|
||||||
isReadonly,
|
|
||||||
isRef,
|
|
||||||
isRuntimeOnly,
|
|
||||||
isShallow,
|
|
||||||
isVNode,
|
|
||||||
markRaw,
|
|
||||||
mergeDefaults,
|
|
||||||
mergeModels,
|
|
||||||
mergeProps,
|
|
||||||
nextTick,
|
|
||||||
nodeOps,
|
|
||||||
normalizeClass,
|
|
||||||
normalizeProps,
|
|
||||||
normalizeStyle,
|
|
||||||
onActivated,
|
|
||||||
onBeforeMount,
|
|
||||||
onBeforeUnmount,
|
|
||||||
onBeforeUpdate,
|
|
||||||
onDeactivated,
|
|
||||||
onErrorCaptured,
|
|
||||||
onMounted,
|
|
||||||
onRenderTracked,
|
|
||||||
onRenderTriggered,
|
|
||||||
onScopeDispose,
|
|
||||||
onServerPrefetch,
|
|
||||||
onUnmounted,
|
|
||||||
onUpdated,
|
|
||||||
onWatcherCleanup,
|
|
||||||
openBlock,
|
|
||||||
patchProp,
|
|
||||||
popScopeId,
|
|
||||||
provide,
|
|
||||||
proxyRefs,
|
|
||||||
pushScopeId,
|
|
||||||
queuePostFlushCb,
|
|
||||||
reactive,
|
|
||||||
readonly,
|
|
||||||
ref,
|
|
||||||
registerRuntimeCompiler,
|
|
||||||
render,
|
|
||||||
renderList,
|
|
||||||
renderSlot,
|
|
||||||
resolveComponent,
|
|
||||||
resolveDirective,
|
|
||||||
resolveDynamicComponent,
|
|
||||||
resolveFilter,
|
|
||||||
resolveTransitionHooks,
|
|
||||||
setBlockTracking,
|
|
||||||
setDevtoolsHook,
|
|
||||||
setTransitionHooks,
|
|
||||||
shallowReactive,
|
|
||||||
shallowReadonly,
|
|
||||||
shallowRef,
|
|
||||||
ssrContextKey,
|
|
||||||
ssrUtils,
|
|
||||||
stop,
|
|
||||||
toDisplayString,
|
|
||||||
toHandlerKey,
|
|
||||||
toHandlers,
|
|
||||||
toRaw,
|
|
||||||
toRef,
|
|
||||||
toRefs,
|
|
||||||
toValue,
|
|
||||||
transformVNodeArgs,
|
|
||||||
triggerRef,
|
|
||||||
unref,
|
|
||||||
useAttrs,
|
|
||||||
useCssModule,
|
|
||||||
useCssVars,
|
|
||||||
useHost,
|
|
||||||
useId,
|
|
||||||
useModel,
|
|
||||||
useSSRContext,
|
|
||||||
useShadowRoot,
|
|
||||||
useSlots,
|
|
||||||
useTemplateRef,
|
|
||||||
useTransitionState,
|
|
||||||
vModelCheckbox,
|
|
||||||
vModelDynamic,
|
|
||||||
vModelRadio,
|
|
||||||
vModelSelect,
|
|
||||||
vModelText,
|
|
||||||
vShow,
|
|
||||||
version,
|
|
||||||
warn,
|
|
||||||
watch,
|
|
||||||
watchEffect,
|
|
||||||
watchPostEffect,
|
|
||||||
watchSyncEffect,
|
|
||||||
withAsyncContext,
|
|
||||||
withCtx,
|
|
||||||
withDefaults,
|
|
||||||
withDirectives,
|
|
||||||
withKeys,
|
|
||||||
withMemo,
|
|
||||||
withModifiers,
|
|
||||||
withScopeId
|
|
||||||
} from "./chunk-LE5NDSFD.js";
|
|
||||||
import "./chunk-PZ5AY32C.js";
|
|
||||||
export {
|
|
||||||
BaseTransition,
|
|
||||||
BaseTransitionPropsValidators,
|
|
||||||
Comment,
|
|
||||||
DeprecationTypes,
|
|
||||||
EffectScope,
|
|
||||||
ErrorCodes,
|
|
||||||
ErrorTypeStrings,
|
|
||||||
Fragment,
|
|
||||||
KeepAlive,
|
|
||||||
ReactiveEffect,
|
|
||||||
Static,
|
|
||||||
Suspense,
|
|
||||||
Teleport,
|
|
||||||
Text,
|
|
||||||
TrackOpTypes,
|
|
||||||
Transition,
|
|
||||||
TransitionGroup,
|
|
||||||
TriggerOpTypes,
|
|
||||||
VueElement,
|
|
||||||
assertNumber,
|
|
||||||
callWithAsyncErrorHandling,
|
|
||||||
callWithErrorHandling,
|
|
||||||
camelize,
|
|
||||||
capitalize,
|
|
||||||
cloneVNode,
|
|
||||||
compatUtils,
|
|
||||||
compile,
|
|
||||||
computed,
|
|
||||||
createApp,
|
|
||||||
createBlock,
|
|
||||||
createCommentVNode,
|
|
||||||
createElementBlock,
|
|
||||||
createBaseVNode as createElementVNode,
|
|
||||||
createHydrationRenderer,
|
|
||||||
createPropsRestProxy,
|
|
||||||
createRenderer,
|
|
||||||
createSSRApp,
|
|
||||||
createSlots,
|
|
||||||
createStaticVNode,
|
|
||||||
createTextVNode,
|
|
||||||
createVNode,
|
|
||||||
customRef,
|
|
||||||
defineAsyncComponent,
|
|
||||||
defineComponent,
|
|
||||||
defineCustomElement,
|
|
||||||
defineEmits,
|
|
||||||
defineExpose,
|
|
||||||
defineModel,
|
|
||||||
defineOptions,
|
|
||||||
defineProps,
|
|
||||||
defineSSRCustomElement,
|
|
||||||
defineSlots,
|
|
||||||
devtools,
|
|
||||||
effect,
|
|
||||||
effectScope,
|
|
||||||
getCurrentInstance,
|
|
||||||
getCurrentScope,
|
|
||||||
getCurrentWatcher,
|
|
||||||
getTransitionRawChildren,
|
|
||||||
guardReactiveProps,
|
|
||||||
h,
|
|
||||||
handleError,
|
|
||||||
hasInjectionContext,
|
|
||||||
hydrate,
|
|
||||||
hydrateOnIdle,
|
|
||||||
hydrateOnInteraction,
|
|
||||||
hydrateOnMediaQuery,
|
|
||||||
hydrateOnVisible,
|
|
||||||
initCustomFormatter,
|
|
||||||
initDirectivesForSSR,
|
|
||||||
inject,
|
|
||||||
isMemoSame,
|
|
||||||
isProxy,
|
|
||||||
isReactive,
|
|
||||||
isReadonly,
|
|
||||||
isRef,
|
|
||||||
isRuntimeOnly,
|
|
||||||
isShallow,
|
|
||||||
isVNode,
|
|
||||||
markRaw,
|
|
||||||
mergeDefaults,
|
|
||||||
mergeModels,
|
|
||||||
mergeProps,
|
|
||||||
nextTick,
|
|
||||||
nodeOps,
|
|
||||||
normalizeClass,
|
|
||||||
normalizeProps,
|
|
||||||
normalizeStyle,
|
|
||||||
onActivated,
|
|
||||||
onBeforeMount,
|
|
||||||
onBeforeUnmount,
|
|
||||||
onBeforeUpdate,
|
|
||||||
onDeactivated,
|
|
||||||
onErrorCaptured,
|
|
||||||
onMounted,
|
|
||||||
onRenderTracked,
|
|
||||||
onRenderTriggered,
|
|
||||||
onScopeDispose,
|
|
||||||
onServerPrefetch,
|
|
||||||
onUnmounted,
|
|
||||||
onUpdated,
|
|
||||||
onWatcherCleanup,
|
|
||||||
openBlock,
|
|
||||||
patchProp,
|
|
||||||
popScopeId,
|
|
||||||
provide,
|
|
||||||
proxyRefs,
|
|
||||||
pushScopeId,
|
|
||||||
queuePostFlushCb,
|
|
||||||
reactive,
|
|
||||||
readonly,
|
|
||||||
ref,
|
|
||||||
registerRuntimeCompiler,
|
|
||||||
render,
|
|
||||||
renderList,
|
|
||||||
renderSlot,
|
|
||||||
resolveComponent,
|
|
||||||
resolveDirective,
|
|
||||||
resolveDynamicComponent,
|
|
||||||
resolveFilter,
|
|
||||||
resolveTransitionHooks,
|
|
||||||
setBlockTracking,
|
|
||||||
setDevtoolsHook,
|
|
||||||
setTransitionHooks,
|
|
||||||
shallowReactive,
|
|
||||||
shallowReadonly,
|
|
||||||
shallowRef,
|
|
||||||
ssrContextKey,
|
|
||||||
ssrUtils,
|
|
||||||
stop,
|
|
||||||
toDisplayString,
|
|
||||||
toHandlerKey,
|
|
||||||
toHandlers,
|
|
||||||
toRaw,
|
|
||||||
toRef,
|
|
||||||
toRefs,
|
|
||||||
toValue,
|
|
||||||
transformVNodeArgs,
|
|
||||||
triggerRef,
|
|
||||||
unref,
|
|
||||||
useAttrs,
|
|
||||||
useCssModule,
|
|
||||||
useCssVars,
|
|
||||||
useHost,
|
|
||||||
useId,
|
|
||||||
useModel,
|
|
||||||
useSSRContext,
|
|
||||||
useShadowRoot,
|
|
||||||
useSlots,
|
|
||||||
useTemplateRef,
|
|
||||||
useTransitionState,
|
|
||||||
vModelCheckbox,
|
|
||||||
vModelDynamic,
|
|
||||||
vModelRadio,
|
|
||||||
vModelSelect,
|
|
||||||
vModelText,
|
|
||||||
vShow,
|
|
||||||
version,
|
|
||||||
warn,
|
|
||||||
watch,
|
|
||||||
watchEffect,
|
|
||||||
watchPostEffect,
|
|
||||||
watchSyncEffect,
|
|
||||||
withAsyncContext,
|
|
||||||
withCtx,
|
|
||||||
withDefaults,
|
|
||||||
withDirectives,
|
|
||||||
withKeys,
|
|
||||||
withMemo,
|
|
||||||
withModifiers,
|
|
||||||
withScopeId
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=vue.js.map
|
|
||||||
7
docs/.vitepress/cache/deps/vue.js.map
vendored
7
docs/.vitepress/cache/deps/vue.js.map
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"sources": [],
|
|
||||||
"sourcesContent": [],
|
|
||||||
"mappings": "",
|
|
||||||
"names": []
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { defineConfig } from 'vitepress'
|
|
||||||
import { sidebarEn, sidebarId } from './config/sidebars'
|
|
||||||
import { navEn, navId } from './config/nav'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
title: "MIVO",
|
|
||||||
description: "Modern Mikrotik Voucher Management System",
|
|
||||||
lang: 'en-US',
|
|
||||||
cleanUrls: true,
|
|
||||||
lastUpdated: true,
|
|
||||||
|
|
||||||
head: [
|
|
||||||
['link', { rel: 'icon', href: '/logo-m.svg' }]
|
|
||||||
],
|
|
||||||
|
|
||||||
// Shared theme config
|
|
||||||
themeConfig: {
|
|
||||||
logo: {
|
|
||||||
light: '/logo-m.svg',
|
|
||||||
dark: '/logo-m-dark.svg'
|
|
||||||
},
|
|
||||||
siteTitle: 'MIVO',
|
|
||||||
|
|
||||||
socialLinks: [
|
|
||||||
{ icon: 'github', link: 'https://github.com/dyzulk/mivo' }
|
|
||||||
],
|
|
||||||
|
|
||||||
footer: {
|
|
||||||
message: 'Released under the MIT License.',
|
|
||||||
copyright: 'Copyright © 2026 DyzulkDev'
|
|
||||||
},
|
|
||||||
|
|
||||||
search: {
|
|
||||||
provider: 'local'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
locales: {
|
|
||||||
root: {
|
|
||||||
label: 'English',
|
|
||||||
lang: 'en',
|
|
||||||
themeConfig: {
|
|
||||||
nav: navEn,
|
|
||||||
sidebar: sidebarEn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
label: 'Indonesia',
|
|
||||||
lang: 'id',
|
|
||||||
themeConfig: {
|
|
||||||
nav: navId,
|
|
||||||
sidebar: sidebarId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { DefaultTheme } from 'vitepress'
|
|
||||||
|
|
||||||
export const navEn: DefaultTheme.NavItem[] = [
|
|
||||||
{ text: 'Home', link: '/' },
|
|
||||||
{ text: 'Guide', link: '/guide/installation' },
|
|
||||||
{ text: 'Manual', link: '/manual/' },
|
|
||||||
{
|
|
||||||
text: 'Community',
|
|
||||||
items: [
|
|
||||||
{ text: 'Changelog', link: 'https://github.com/dyzulk/mivo/releases' },
|
|
||||||
{ text: 'Contributing', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const navId: DefaultTheme.NavItem[] = [
|
|
||||||
{ text: 'Beranda', link: '/id/' },
|
|
||||||
{ text: 'Panduan', link: '/id/guide/installation' },
|
|
||||||
{ text: 'Buku Manual', link: '/id/manual/' },
|
|
||||||
{
|
|
||||||
text: 'Komunitas',
|
|
||||||
items: [
|
|
||||||
{ text: 'Catatan Rilis', link: 'https://github.com/dyzulk/mivo/releases' },
|
|
||||||
{ text: 'Kontribusi', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { DefaultTheme } from 'vitepress'
|
|
||||||
|
|
||||||
// English Sidebars
|
|
||||||
export const sidebarEn: DefaultTheme.Sidebar = {
|
|
||||||
// Sidebar for /guide/ path
|
|
||||||
'/guide/': [
|
|
||||||
{
|
|
||||||
text: 'Getting Started',
|
|
||||||
collapsed: false,
|
|
||||||
items: [
|
|
||||||
{ text: 'Introduction', link: '/guide/' },
|
|
||||||
{ text: 'Requirements', link: '/guide/installation#requirements' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Installation',
|
|
||||||
collapsed: false,
|
|
||||||
items: [
|
|
||||||
{ text: 'Docker', link: '/guide/docker' },
|
|
||||||
{ text: 'Web Server', link: '/guide/installation#web-servers' },
|
|
||||||
{ text: 'Shared Hosting', link: '/guide/installation#shared-hosting' },
|
|
||||||
{ text: 'VPS & Cloud', link: '/guide/installation#vps-cloud' },
|
|
||||||
{ text: 'Mobile & STB', link: '/guide/installation#mobile-stb' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Configuration',
|
|
||||||
items: [
|
|
||||||
{ text: 'Post-Installation', link: '/guide/installation#post-installation' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Support',
|
|
||||||
items: [
|
|
||||||
{ text: 'Contribution', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
|
|
||||||
{ text: 'Donate', link: 'https://sociabuzz.com/dyzulkdev/tribe' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
// Sidebar for /manual/ path
|
|
||||||
'/manual/': [
|
|
||||||
{
|
|
||||||
text: 'User Manual',
|
|
||||||
items: [
|
|
||||||
{ text: 'Overview', link: '/manual/' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Global Settings',
|
|
||||||
items: [
|
|
||||||
{ text: 'Introduction', link: '/manual/settings/' },
|
|
||||||
{ text: 'Routers', link: '/manual/settings/routers' },
|
|
||||||
{ text: 'Templates', link: '/manual/settings/templates' },
|
|
||||||
{ text: 'Logos', link: '/manual/settings/logos' },
|
|
||||||
{ text: 'API & CORS', link: '/manual/settings/api-cors' },
|
|
||||||
{ text: 'System', link: '/manual/settings/system' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Router Operations',
|
|
||||||
items: [
|
|
||||||
{ text: 'Introduction', link: '/manual/router/' },
|
|
||||||
{ text: 'Dashboard', link: '/manual/router/dashboard' },
|
|
||||||
{ text: 'Quick Print', link: '/manual/router/quick-print' },
|
|
||||||
{ text: 'Hotspot Management', link: '/manual/router/hotspot' },
|
|
||||||
{ text: 'Reports & Logs', link: '/manual/router/reports' },
|
|
||||||
{ text: 'Network & System', link: '/manual/router/tools' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indonesian Sidebars
|
|
||||||
export const sidebarId: DefaultTheme.Sidebar = {
|
|
||||||
// Sidebar for /id/guide/ path
|
|
||||||
'/id/guide/': [
|
|
||||||
{
|
|
||||||
text: 'Pengenalan',
|
|
||||||
collapsed: false,
|
|
||||||
items: [
|
|
||||||
{ text: 'Apa itu MIVO?', link: '/id/guide/' },
|
|
||||||
{ text: 'Persyaratan', link: '/id/guide/installation#persyaratan' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Instalasi',
|
|
||||||
collapsed: false,
|
|
||||||
items: [
|
|
||||||
{ text: 'Docker', link: '/id/guide/docker' },
|
|
||||||
{ text: 'Web Server', link: '/id/guide/installation#web-server' },
|
|
||||||
{ text: 'Shared Hosting', link: '/id/guide/installation#shared-hosting' },
|
|
||||||
{ text: 'VPS & Cloud', link: '/id/guide/installation#vps-cloud' },
|
|
||||||
{ text: 'Mobile & STB', link: '/id/guide/installation#mobile-stb' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Konfigurasi',
|
|
||||||
items: [
|
|
||||||
{ text: 'Pasca-Instalasi', link: '/id/guide/installation#pasca-instalasi' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Dukungan',
|
|
||||||
items: [
|
|
||||||
{ text: 'Kontribusi', link: 'https://github.com/dyzulk/mivo/blob/main/CONTRIBUTING.md' },
|
|
||||||
{ text: 'Donasi', link: 'https://sociabuzz.com/dyzulkdev/tribe' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
// Sidebar for /id/manual/ path
|
|
||||||
'/id/manual/': [
|
|
||||||
{
|
|
||||||
text: 'Buku Manual',
|
|
||||||
items: [
|
|
||||||
{ text: 'Ringkasan', link: '/id/manual/' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Pengaturan Global',
|
|
||||||
items: [
|
|
||||||
{ text: 'Pendahuluan', link: '/id/manual/settings/' },
|
|
||||||
{ text: 'Router', link: '/id/manual/settings/routers' },
|
|
||||||
{ text: 'Template', link: '/id/manual/settings/templates' },
|
|
||||||
{ text: 'Logo', link: '/id/manual/settings/logos' },
|
|
||||||
{ text: 'API & CORS', link: '/id/manual/settings/api-cors' },
|
|
||||||
{ text: 'Sistem', link: '/id/manual/settings/system' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Operasional Router',
|
|
||||||
items: [
|
|
||||||
{ text: 'Pendahuluan', link: '/id/manual/router/' },
|
|
||||||
{ text: 'Dashboard', link: '/id/manual/router/dashboard' },
|
|
||||||
{ text: 'Cetak Cepat', link: '/id/manual/router/quick-print' },
|
|
||||||
{ text: 'Manajemen Hotspot', link: '/id/manual/router/hotspot' },
|
|
||||||
{ text: 'Laporan & Log', link: '/id/manual/router/reports' },
|
|
||||||
{ text: 'Jaringan & Sistem', link: '/id/manual/router/tools' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import DefaultTheme from 'vitepress/theme'
|
|
||||||
|
|
||||||
const { Layout } = DefaultTheme
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Layout>
|
|
||||||
<template #layout-bottom>
|
|
||||||
<div class="mivo-bg">
|
|
||||||
<!-- Subtle Grid Pattern -->
|
|
||||||
<div class="mivo-grid"></div>
|
|
||||||
<!-- Glowing Orbs -->
|
|
||||||
<div class="mivo-orb orb-1"></div>
|
|
||||||
<div class="mivo-orb orb-2"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Layout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mivo-bg {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mivo-grid {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMCwwLDAsMC4zKSIvPjwvc3ZnPg==');
|
|
||||||
mask-image: linear-gradient(to bottom, white, transparent);
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, white, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .mivo-grid {
|
|
||||||
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wNSkiLz48L3N2Zz4=');
|
|
||||||
}
|
|
||||||
|
|
||||||
.mivo-orb {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 9999px;
|
|
||||||
filter: blur(100px);
|
|
||||||
opacity: 0.2;
|
|
||||||
animation: pulse 8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .mivo-orb {
|
|
||||||
opacity: 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orb-1 {
|
|
||||||
top: -20%;
|
|
||||||
left: -10%;
|
|
||||||
width: 70vw;
|
|
||||||
height: 70vw;
|
|
||||||
background-color: #3b82f6; /* blue-500 */
|
|
||||||
animation-duration: 4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orb-2 {
|
|
||||||
top: 30%;
|
|
||||||
right: -15%;
|
|
||||||
width: 60vw;
|
|
||||||
height: 60vw;
|
|
||||||
background-color: #a855f7; /* purple-500 */
|
|
||||||
animation-duration: 6s;
|
|
||||||
animation-delay: 1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.2; }
|
|
||||||
50% { opacity: 0.15; }
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark @keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.05; }
|
|
||||||
50% { opacity: 0.03; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<template>
|
|
||||||
<component
|
|
||||||
:is="iconComponent"
|
|
||||||
v-if="iconComponent"
|
|
||||||
:size="size || 20"
|
|
||||||
:stroke-width="strokeWidth || 2"
|
|
||||||
class="lucide-icon"
|
|
||||||
:style="{ color: resolvedColor }"
|
|
||||||
v-bind="$attrs"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import * as icons from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
size: [Number, String],
|
|
||||||
strokeWidth: [Number, String],
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: 'base'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const semanticColors = {
|
|
||||||
base: 'var(--mivo-icon-base)',
|
|
||||||
muted: 'var(--mivo-icon-muted)',
|
|
||||||
primary: 'var(--mivo-icon-primary)',
|
|
||||||
info: 'var(--mivo-icon-info)',
|
|
||||||
success: 'var(--mivo-icon-success)',
|
|
||||||
warning: 'var(--mivo-icon-warning)',
|
|
||||||
danger: 'var(--mivo-icon-danger)'
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedColor = computed(() => {
|
|
||||||
return semanticColors[props.color] || props.color
|
|
||||||
})
|
|
||||||
|
|
||||||
const iconComponent = computed(() => {
|
|
||||||
// Handle both PascalCase (Search) and kebab-case (search-icon)
|
|
||||||
const pascalName = props.name
|
|
||||||
.split('-')
|
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
return icons[pascalName] || icons[props.name]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.lucide-icon {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import DefaultTheme from 'vitepress/theme'
|
|
||||||
import Layout from './Layout.vue'
|
|
||||||
import Icon from './components/Icon.vue'
|
|
||||||
import 'flag-icons/css/flag-icons.min.css'
|
|
||||||
import './style.css'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
extends: DefaultTheme,
|
|
||||||
Layout: Layout,
|
|
||||||
enhanceApp({ app }) {
|
|
||||||
app.component('Icon', Icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
/* Fonts Setup */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Geist';
|
|
||||||
src: url('/assets/fonts/Geist-Regular.woff2') format('woff2');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Geist';
|
|
||||||
src: url('/assets/fonts/Geist-Bold.woff2') format('woff2');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Geist Mono';
|
|
||||||
src: url('/assets/fonts/GeistMono-Regular.woff2') format('woff2');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Fonts */
|
|
||||||
--vp-font-family-base: "Geist", sans-serif;
|
|
||||||
--vp-font-family-mono: "Geist Mono", monospace;
|
|
||||||
|
|
||||||
/* Colors Override to match MIVO */
|
|
||||||
--vp-c-bg: #ffffff;
|
|
||||||
--vp-c-bg-alt: #fafafa;
|
|
||||||
--vp-c-bg-elv: #ffffff;
|
|
||||||
--vp-c-text-1: #000000;
|
|
||||||
--vp-c-text-2: #666666;
|
|
||||||
|
|
||||||
--vp-c-brand-1: #000000;
|
|
||||||
--vp-c-brand-2: #333333;
|
|
||||||
--vp-c-brand-3: #666666;
|
|
||||||
|
|
||||||
/* Icon Colors - Light */
|
|
||||||
--mivo-icon-base: var(--vp-c-text-1);
|
|
||||||
--mivo-icon-muted: var(--vp-c-text-2);
|
|
||||||
--mivo-icon-primary: var(--vp-c-brand-1);
|
|
||||||
--mivo-icon-info: #3b82f6;
|
|
||||||
--mivo-icon-success: #22c55e;
|
|
||||||
--mivo-icon-warning: #eab308;
|
|
||||||
--mivo-icon-danger: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--vp-c-bg: #000000;
|
|
||||||
--vp-c-bg-alt: #111111;
|
|
||||||
--vp-c-bg-elv: #111111;
|
|
||||||
--vp-c-text-1: #ffffff;
|
|
||||||
--vp-c-text-2: #888888;
|
|
||||||
|
|
||||||
--vp-c-brand-1: #ffffff;
|
|
||||||
--vp-c-brand-2: #eaeaea;
|
|
||||||
--vp-c-brand-3: #999999;
|
|
||||||
|
|
||||||
/* Icon Colors - Dark */
|
|
||||||
--mivo-icon-base: var(--vp-c-text-1);
|
|
||||||
--mivo-icon-muted: var(--vp-c-text-2);
|
|
||||||
--mivo-icon-primary: var(--vp-c-brand-1);
|
|
||||||
--mivo-icon-info: #60a5fa;
|
|
||||||
--mivo-icon-success: #4ade80;
|
|
||||||
--mivo-icon-warning: #facc15;
|
|
||||||
--mivo-icon-danger: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glassmorphism Overrides */
|
|
||||||
|
|
||||||
/* Navbar Glass */
|
|
||||||
.VPNav {
|
|
||||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
.dark .VPNav {
|
|
||||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Glass */
|
|
||||||
.VPSidebar {
|
|
||||||
background-color: rgba(255, 255, 255, 0.3) !important;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-right: 1px solid rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
.dark .VPSidebar {
|
|
||||||
background-color: rgba(0, 0, 0, 0.3) !important;
|
|
||||||
border-right: 1px solid rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content Container - Transparent to show particles */
|
|
||||||
.VPContent {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer Glass */
|
|
||||||
.VPFooter {
|
|
||||||
background-color: transparent !important;
|
|
||||||
border-top: 1px solid rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
.dark .VPFooter {
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Local Search Box Glass */
|
|
||||||
.VPLocalSearchBox {
|
|
||||||
background-color: rgba(255, 255, 255, 0.8) !important;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
|
||||||
.dark .VPLocalSearchBox {
|
|
||||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glassmorphism Cards (Feature Cards) */
|
|
||||||
.VPFeature {
|
|
||||||
background-color: rgba(255, 255, 255, 0.25) !important;
|
|
||||||
backdrop-filter: blur(40px);
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
|
||||||
border-radius: 1rem !important; /* rounded-2xl */
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .VPFeature {
|
|
||||||
background-color: rgba(0, 0, 0, 0.4) !important;
|
|
||||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPFeature:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.4) !important;
|
|
||||||
background-color: rgba(255, 255, 255, 0.4) !important;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .VPFeature:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.2) !important;
|
|
||||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix text colors inside cards if needed */
|
|
||||||
/* Global Button Styling (MIVO Style) */
|
|
||||||
.VPButton {
|
|
||||||
border-radius: 0.375rem !important; /* rounded-md */
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
letter-spacing: normal !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPButton:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPButton:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Primary Button (Brand) */
|
|
||||||
.VPButton.brand {
|
|
||||||
background-color: var(--vp-c-brand-1) !important;
|
|
||||||
border-color: var(--vp-c-brand-1) !important;
|
|
||||||
color: var(--vp-c-bg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .VPButton.brand {
|
|
||||||
color: #000 !important; /* Ensure black text on white button in dark mode */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Secondary Button (Alt) */
|
|
||||||
.VPButton.alt {
|
|
||||||
background-color: transparent !important;
|
|
||||||
border: 1px solid var(--vp-c-brand-2) !important;
|
|
||||||
color: var(--vp-c-text-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Navigation (Glass Overlay) */
|
|
||||||
.VPNavScreen {
|
|
||||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
position: fixed !important;
|
|
||||||
top: var(--vp-nav-height, 64px) !important;
|
|
||||||
bottom: 0 !important;
|
|
||||||
left: 0 !important;
|
|
||||||
right: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
height: calc(100vh - var(--vp-nav-height, 64px)) !important; /* Force Height */
|
|
||||||
z-index: 90 !important;
|
|
||||||
overflow-y: auto !important;
|
|
||||||
opacity: 1 !important; /* Always opacity 1 if visible */
|
|
||||||
pointer-events: auto !important;
|
|
||||||
/* Remove transition that might delay visibility */
|
|
||||||
transition: background-color 0.25s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rely on VitePress/Vue to toggle 'display' property */
|
|
||||||
/* We just ensure that IF it is displayed, it looks right */
|
|
||||||
|
|
||||||
.dark .VPNavScreen {
|
|
||||||
background-color: rgba(0, 0, 0, 0.95) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Sidebar (Slide-out) */
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.VPSidebar {
|
|
||||||
background-color: rgba(255, 255, 255, 0.9) !important; /* Less transparent on mobile for readability */
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-right: none !important;
|
|
||||||
box-shadow: 10px 0 30px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .VPSidebar {
|
|
||||||
background-color: rgba(20, 20, 20, 0.9) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix Hamburger Menu Icon Color & Interaction */
|
|
||||||
.VPNavBarHamburger {
|
|
||||||
cursor: pointer !important;
|
|
||||||
pointer-events: auto !important;
|
|
||||||
z-index: 100 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPNavBarHamburger .container .top,
|
|
||||||
.VPNavBarHamburger .container .middle,
|
|
||||||
.VPNavBarHamburger .container .bottom {
|
|
||||||
background-color: var(--vp-c-text-1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure Logo Text is Visible on Mobile */
|
|
||||||
/* High Contrast Sidebar & TOC */
|
|
||||||
/* High Contrast Sidebar & TOC */
|
|
||||||
.VPSidebarItem .text {
|
|
||||||
color: var(--vp-c-text-2) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
font-weight: 400 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Level 0 Parent Headers (Collapsible) */
|
|
||||||
.VPSidebarItem.level-0 > .item > .text,
|
|
||||||
.VPSidebarItem.level-0 > .item > .VPLink > .text {
|
|
||||||
color: var(--vp-c-text-1) !important;
|
|
||||||
font-weight: 700 !important; /* Bold for headers */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Level 1+ Sub-items explicitly regular */
|
|
||||||
.VPSidebarItem.level-1 .text,
|
|
||||||
.VPSidebarItem.level-2 .text {
|
|
||||||
font-weight: 400 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Lines (GitBook Style) */
|
|
||||||
.VPSidebarItem.level-0 > .items {
|
|
||||||
border-left: 1px solid rgba(0, 0, 0, 0.05); /* Light vertical line */
|
|
||||||
margin-left: 1.15rem;
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .VPSidebarItem.level-0 > .items {
|
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active Sidebar Item Differentiation */
|
|
||||||
.VPSidebarItem.is-active > .item {
|
|
||||||
border-left: 2px solid var(--vp-c-brand-1); /* Bold indicator line */
|
|
||||||
margin-left: calc(-0.5rem - 1px); /* Overlap the group line */
|
|
||||||
padding-left: calc(0.5rem - 1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPSidebarItem.is-active > .item > .text,
|
|
||||||
.VPSidebarItem.is-active > .item > .VPLink > .text {
|
|
||||||
color: var(--vp-c-brand-1) !important;
|
|
||||||
font-weight: 400 !important; /* Keep active sub-item thinner than collapsible header */
|
|
||||||
background-color: transparent !important; /* Clean style */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar Active Underline */
|
|
||||||
.VPNavBarMenuLink.active {
|
|
||||||
color: var(--vp-c-brand-1) !important;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPNavBarMenuLink.active::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
bottom: -4px;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background-color: var(--vp-c-brand-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPDocOutlineItem .text {
|
|
||||||
color: var(--vp-c-text-2) !important;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 400 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPDocOutlineItem.active .text {
|
|
||||||
color: var(--vp-c-brand-1) !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark Mode Specific Contrast Boost */
|
|
||||||
.dark .VPSidebarItem .text,
|
|
||||||
.dark .VPDocOutlineItem .text {
|
|
||||||
color: #b0b0b0 !important; /* Brighter than default gray */
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .VPSidebarItem.is-active > .item > .text,
|
|
||||||
.dark .VPSidebarItem.is-active > .item > .VPLink > .text,
|
|
||||||
.dark .VPDocOutlineItem.active .text,
|
|
||||||
.dark .VPSidebarItem.level-0 > .item > .text,
|
|
||||||
.dark .VPSidebarItem.level-0 > .item > .VPLink > .text {
|
|
||||||
color: #ffffff !important;
|
|
||||||
/* Font weights are already inherited or set above */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix Code Block Background to be Glassy too */
|
|
||||||
.vp-code-group .tabs {
|
|
||||||
background-color: rgba(255,255,255,0.5) !important;
|
|
||||||
}
|
|
||||||
.dark .vp-code-group .tabs {
|
|
||||||
background-color: rgba(0,0,0,0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown Divider Contrast */
|
|
||||||
.vp-doc hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.15); /* Stronger in light mode */
|
|
||||||
margin: 3rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .vp-doc hr {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.2); /* Stronger in dark mode */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination Cards */
|
|
||||||
.pager-link {
|
|
||||||
background-color: rgba(255, 255, 255, 0.4) !important;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05) !important;
|
|
||||||
border-radius: 0.75rem !important; /* rounded-xl */
|
|
||||||
padding: 1.5rem !important;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
|
||||||
text-decoration: none !important;
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .pager-link {
|
|
||||||
background-color: rgba(255, 255, 255, 0.03) !important;
|
|
||||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager-link:hover {
|
|
||||||
border-color: var(--vp-c-brand-1) !important;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .pager-link:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager-link .desc {
|
|
||||||
color: var(--vp-c-text-2) !important;
|
|
||||||
font-size: 0.8rem !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
margin-bottom: 4px !important;
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager-link .title {
|
|
||||||
color: var(--vp-c-brand-1) !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager-link.next {
|
|
||||||
text-align: right !important;
|
|
||||||
align-items: flex-end !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Language Switcher Flags Integration */
|
|
||||||
.VPNavBarTranslations .items .title::before,
|
|
||||||
.VPNavBarTranslations .items .VPMenuLink .VPLink.link span::before {
|
|
||||||
content: "";
|
|
||||||
display: inline-block;
|
|
||||||
width: 1.33333333em;
|
|
||||||
height: 1em;
|
|
||||||
margin-right: 0.6rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
background-size: contain;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- English Flag (US) --- */
|
|
||||||
|
|
||||||
/* 1. Target the link to English (Any link NOT starting with /id/) */
|
|
||||||
.VPNavBarTranslations a:not([href^="/id/"])::before,
|
|
||||||
.VPNavBarTranslations a:not([href^="/id/"]) span::before {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23bd3d44' d='M0 0h640v480H0z'/%3E%3Cpath stroke='%23fff' stroke-width='37' d='M0 74h640M0 148h640M0 222h640M0 296h640M0 370h640M0 444h640'/%3E%3Cpath fill='%23192f5d' d='M0 0h364v258.5H0z'/%3E%3Cg fill='%23fff'%3E%3Cg id='a'%3E%3Cg id='b'%3E%3Cpath id='c' d='M31 23l5 15.5-13.1-9.5H44l-13.1 9.5'/%3E%3Cuse href='%23c' x='62'/%3E%3Cuse href='%23c' x='124'/%3E%3Cuse href='%23c' x='186'/%3E%3Cuse href='%23c' x='248'/%3E%3C/g%3E%3Cuse href='%23b' x='31' y='21'/%3E%3C/g%3E%3Cuse href='%23a' y='42'/%3E%3Cuse href='%23a' y='84'/%3E%3Cuse href='%23a' y='126'/%3E%3Cuse href='%23a' y='168'/%3E%3C/g%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. Target the active English title (when on /) */
|
|
||||||
html[lang="en"] .VPNavBarTranslations .items .title::before {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23bd3d44' d='M0 0h640v480H0z'/%3E%3Cpath stroke='%23fff' stroke-width='37' d='M0 74h640M0 148h640M0 222h640M0 296h640M0 370h640M0 444h640'/%3E%3Cpath fill='%23192f5d' d='M0 0h364v258.5H0z'/%3E%3Cg fill='%23fff'%3E%3Cg id='a'%3E%3Cg id='b'%3E%3Cpath id='c' d='M31 23l5 15.5-13.1-9.5H44l-13.1 9.5'/%3E%3Cuse href='%23c' x='62'/%3E%3Cuse href='%23c' x='124'/%3E%3Cuse href='%23c' x='186'/%3E%3Cuse href='%23c' x='248'/%3E%3C/g%3E%3Cuse href='%23b' x='31' y='21'/%3E%3C/g%3E%3Cuse href='%23a' y='42'/%3E%3Cuse href='%23a' y='84'/%3E%3Cuse href='%23a' y='126'/%3E%3Cuse href='%23a' y='168'/%3E%3C/g%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Indonesia Flag (ID) --- */
|
|
||||||
|
|
||||||
/* 1. Target the link to Indonesia (Any link starting with /id/) */
|
|
||||||
.VPNavBarTranslations a[href^="/id/"]::before,
|
|
||||||
.VPNavBarTranslations a[href^="/id/"] span::before {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23e12127' d='M0 0h640v240H0z'/%3E%3Cpath fill='%23fff' d='M0 240h640v240H0z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. Target the active Indonesia title (when on /id/) */
|
|
||||||
html[lang="id"] .VPNavBarTranslations .items .title::before {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 480'%3E%3Cpath fill='%23e12127' d='M0 0h640v240H0z'/%3E%3Cpath fill='%23fff' d='M0 240h640v240H0z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ The easiest way to run MIVO.
|
|||||||
2. **Manual Pull (Alternative)**
|
2. **Manual Pull (Alternative)**
|
||||||
If you prefer to pull the image manually:
|
If you prefer to pull the image manually:
|
||||||
```bash
|
```bash
|
||||||
docker pull dyzulk/mivo:latest # Stable
|
docker pull ghcr.io/mivodev/mivo:latest # Stable
|
||||||
docker pull dyzulk/mivo:v1.0.0 # Specific Version
|
docker pull ghcr.io/mivodev/mivo:v1.0.0 # Specific Version
|
||||||
docker pull dyzulk/mivo:edge # Bleeding Edge
|
docker pull ghcr.io/mivodev/mivo:edge # Bleeding Edge
|
||||||
```
|
```
|
||||||
|
|
||||||
*Note: The database is persisted in `app/Database` via volumes.*
|
*Note: The database is persisted in `app/Database` via volumes.*
|
||||||
|
|||||||
1
docs/README.md
Normal file
1
docs/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Documentation has moved to https://mivodev.github.io
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
title: Docker Guide
|
|
||||||
---
|
|
||||||
|
|
||||||
# Docker Guide
|
|
||||||
|
|
||||||
This Docker image is built on **Alpine Linux** and **Nginx**, optimized for high performance and low resource usage.
|
|
||||||
|
|
||||||
## <Icon name="Zap" color="warning" /> Quick Start
|
|
||||||
|
|
||||||
Run MIVO in a single command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mivo \
|
|
||||||
-p 8080:80 \
|
|
||||||
-e APP_KEY=base64:YOUR_GENERATED_KEY \
|
|
||||||
-e APP_ENV=production \
|
|
||||||
-v mivo_data:/var/www/html/app/Database \
|
|
||||||
-v mivo_config:/var/www/html/.env \
|
|
||||||
dyzulk/mivo:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Open your browser and navigate to `http://localhost:8080`.
|
|
||||||
|
|
||||||
**Initial Setup:**
|
|
||||||
If this is your first run, you will be redirected to the **Web Installer**. Follow the on-screen instructions to create the database and admin account.
|
|
||||||
|
|
||||||
## <Icon name="Wrench" color="primary" /> Docker Compose
|
|
||||||
|
|
||||||
For a more permanent setup, use `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
mivo:
|
|
||||||
image: dyzulk/mivo:latest
|
|
||||||
container_name: mivo
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
environment:
|
|
||||||
- APP_ENV=production
|
|
||||||
- TZ=Asia/Jakarta
|
|
||||||
volumes:
|
|
||||||
- ./mivo-data:/var/www/html/app/Database
|
|
||||||
```
|
|
||||||
|
|
||||||
## <Icon name="Tags" color="info" /> Tags
|
|
||||||
|
|
||||||
- `latest`: Stable release (recommended).
|
|
||||||
- `edge`: Bleeding edge build from the `main` branch.
|
|
||||||
- `v1.x.x`: Specific released versions.
|
|
||||||
|
|
||||||
## <Icon name="Sliders" color="success" /> Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `APP_ENV` | Application environment (`production` or `local`). | `production` |
|
|
||||||
| `APP_DEBUG` | Enable debug mode (`true` or `false`). | `false` |
|
|
||||||
| `APP_KEY` | 32-character random string (base64). Auto-generated on first install if not provided. | |
|
|
||||||
| `TZ` | Timezone for the container. | `UTC` |
|
|
||||||
|
|
||||||
## <Icon name="Folder" color="primary" /> Volumes
|
|
||||||
|
|
||||||
Persist your data by mounting these paths:
|
|
||||||
|
|
||||||
- `/var/www/html/app/Database`: Stores the SQLite database and session files. **(Critical)**
|
|
||||||
- `/var/www/html/public/assets/img/logos`: Stores uploaded custom logos.
|
|
||||||
|
|
||||||
## <Icon name="Heart" color="danger" /> Support the Project
|
|
||||||
|
|
||||||
If you find MIVO useful, please consider supporting its development. Your contribution helps keep the project alive!
|
|
||||||
|
|
||||||
[](https://sociabuzz.com/dyzulkdev/tribe)
|
|
||||||
|
|
||||||
---
|
|
||||||
*Created with <Icon name="Heart" color="danger" /> by DyzulkDev*
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
title: Introduction
|
|
||||||
---
|
|
||||||
|
|
||||||
# Introduction
|
|
||||||
|
|
||||||
Welcome to the MIVO Guide. This section will help you understand what MIVO is and how to get it running on your system.
|
|
||||||
|
|
||||||
## <Icon name="Zap" color="warning" /> What is MIVO?
|
|
||||||
|
|
||||||
MIVO is a modern, lightweight Mikrotik Voucher Management system. It's a complete rewrite of the legendary Mikhmon v3, re-engineered for better performance and a premium user experience.
|
|
||||||
|
|
||||||
## <Icon name="BookOpen" color="primary" /> Navigation
|
|
||||||
|
|
||||||
Explore the following sections to get started:
|
|
||||||
|
|
||||||
- **[Installation Guide](/guide/installation)**: Learn how to install MIVO on various platforms.
|
|
||||||
- **[Docker Guide](/guide/docker)**: The recommended way to run MIVO using containers.
|
|
||||||
- **[Manual](/manual/)**: Detailed instructions on how to use MIVO features.
|
|
||||||
|
|
||||||
## <Icon name="Heart" color="danger" /> Support
|
|
||||||
|
|
||||||
MIVO is an open-source project. If you find it useful, please consider supporting the development through [donations](https://sociabuzz.com/dyzulkdev/tribe) or [contributing](https://github.com/dyzulk/mivo) to the codebase.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user