mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 13:31:56 +07:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a01aa973 | ||
|
|
ca1fef86bd | ||
|
|
51ca6d3669 | ||
|
|
9cee55c05a | ||
|
|
a0e8c097f7 | ||
|
|
aee64ac137 | ||
|
|
bbda8eaca1 | ||
|
|
18a525e438 | ||
|
|
6c92985707 | ||
|
|
d4691bc700 | ||
|
|
74b258b12d | ||
|
|
d5939dc5a2 | ||
|
|
c95c8b08ea | ||
|
|
b245f31236 | ||
|
|
e8ffea2c58 | ||
|
|
a4d0233386 | ||
|
|
95ca189679 | ||
|
|
5b0b6de2dc | ||
|
|
64609a5821 | ||
|
|
08960b540f | ||
|
|
d8c1a779b8 | ||
|
|
4968246911 | ||
|
|
ae65ab30fa | ||
|
|
6eb6bbb359 | ||
|
|
7a0c6cb5c3 |
@@ -1,5 +1,6 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.env
|
||||
node_modules
|
||||
deploy_package.tar.gz
|
||||
@@ -10,3 +11,4 @@ docs/
|
||||
app/Database/*.sqlite
|
||||
public/assets/img/logos/*
|
||||
!public/assets/img/logos/.gitignore
|
||||
CNAME
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
APP_NAME=MIVO
|
||||
APP_ENV=production
|
||||
APP_KEY=mikhmonv3remake_secret_key_32bytes
|
||||
APP_KEY=mivo_official_secret_key_32bytes
|
||||
APP_DEBUG=true
|
||||
|
||||
# Database
|
||||
|
||||
12
.gitattributes
vendored
12
.gitattributes
vendored
@@ -2,11 +2,21 @@
|
||||
/docs export-ignore
|
||||
/.github export-ignore
|
||||
/docker export-ignore
|
||||
/CNAME export-ignore
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
/.dockerignore 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
|
||||
/build_release.ps1 export-ignore
|
||||
/deploy.ps1 export-ignore
|
||||
/serve.bat export-ignore
|
||||
/phpstan.neon export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
/aapanel_deploy.sh export-ignore
|
||||
9
.github/release_template.md
vendored
Normal file
9
.github/release_template.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
MIVO is a Modern, Lightweight, and Efficient. Built for low-end devices with premium UX.
|
||||
|
||||
## Installation
|
||||
For the best experience, we recommend using **Docker**.
|
||||
[Read the full Docker Installation Guide](https://mivodev.github.io/docs/guide/docker)
|
||||
|
||||
## Notes
|
||||
- Ensure your server runs **PHP 8.0+** with `sqlite3` extension enabled.
|
||||
- Default installation will guide you to create an Admin account.
|
||||
97
.github/scripts/generate-release-notes.js
vendored
Normal file
97
.github/scripts/generate-release-notes.js
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
const { GoogleGenerativeAI } = require("@google/generative-ai");
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Configuration
|
||||
const API_KEY = process.env.GEMINI_API_KEY;
|
||||
const MODEL_NAME = process.env.GEMINI_MODEL || "gemini-2.5-flash";
|
||||
const VERSION_TAG = process.argv[2]; // e.g., v1.2.0
|
||||
// Fix for Windows: Avoid 2>/dev/null, handle error in try-catch block instead
|
||||
const PREVIOUS_TAG_CMD = `git describe --abbrev=0 --tags ${VERSION_TAG}~1`;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("Error: GEMINI_API_KEY is not set.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!VERSION_TAG) {
|
||||
console.error("Error: Version tag must be provided as the first argument.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
console.log(`Generating release notes for ${VERSION_TAG} using ${MODEL_NAME}...`);
|
||||
|
||||
// 1. Get Previous Tag
|
||||
let previousTag;
|
||||
try {
|
||||
previousTag = execSync(PREVIOUS_TAG_CMD).toString().trim();
|
||||
} catch (e) {
|
||||
console.log("No previous tag found, assuming first release.");
|
||||
previousTag = execSync("git rev-list --max-parents=0 HEAD").toString().trim();
|
||||
}
|
||||
console.log(`Comparing from ${previousTag} to ${VERSION_TAG}`);
|
||||
|
||||
// 2. Get Commit Messages
|
||||
const commits = execSync(`git log ${previousTag}..${VERSION_TAG} --pretty=format:"- %s (%h)" --no-merges`).toString();
|
||||
|
||||
if (!commits) {
|
||||
console.log("No commits found between tags.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Generate Content with Gemini
|
||||
const genAI = new GoogleGenerativeAI(API_KEY);
|
||||
const model = genAI.getGenerativeModel({ model: MODEL_NAME });
|
||||
|
||||
const prompt = `
|
||||
You are a release note generator for a software project named 'Mivo'.
|
||||
|
||||
Here are the commits for the new version ${VERSION_TAG}:
|
||||
${commits}
|
||||
|
||||
Please generate a clean, professional release note in Markdown format.
|
||||
|
||||
Strict Rules:
|
||||
1. **NO EMOJIS**: Do not use any emojis in headers, bullet points, or text.
|
||||
2. **Structure**: Group changes strictly into these headers (if applicable):
|
||||
- ### Features
|
||||
- ### Bug Fixes
|
||||
- ### Improvements
|
||||
- ### Maintenance
|
||||
3. **Format**: Use simple bullet points (-) for each item.
|
||||
4. **Content**: Keep it concise but descriptive. Do not mention 'Merge pull request' commits.
|
||||
5. **Header**: Start with a simple header: "# Release Notes ${VERSION_TAG}"
|
||||
6. **Output**: Output ONLY the markdown content.
|
||||
`;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = await result.response;
|
||||
const text = response.text();
|
||||
|
||||
// 4. Read Template (Optional) and Merge
|
||||
// For now, we just output the AI text. You can append this to a template if needed.
|
||||
|
||||
// Write to file
|
||||
const outputPath = path.join(process.cwd(), ".github", "release_notes.md");
|
||||
fs.writeFileSync(outputPath, text);
|
||||
|
||||
console.log(`Release notes generated at ${outputPath}`);
|
||||
console.log(text);
|
||||
|
||||
// Export for GitHub Actions
|
||||
const githubOutput = process.env.GITHUB_OUTPUT;
|
||||
if (githubOutput) {
|
||||
// Multiline string for GitHub Output
|
||||
fs.appendFileSync(githubOutput, `RELEASE_NOTES<<EOF\n${text}\nEOF\n`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to generate release notes:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
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:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: docker.io
|
||||
REGISTRY: ghcr.io
|
||||
# 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:
|
||||
build:
|
||||
@@ -30,13 +33,21 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
# Login against GHCR
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
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 }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
@@ -46,13 +57,12 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
docker.io/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Branch main -> dyzulk/mivo:edge
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
# Tag v1.0.0 -> dyzulk/mivo:1.0.0
|
||||
type=ref,event=tag
|
||||
# Tag v1.0.0 -> dyzulk/mivo:latest
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
|
||||
76
.github/workflows/release.yml
vendored
Normal file
76
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mbstring, xml, ctype, iconv, sqlite3, openssl
|
||||
coverage: none
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release Directory
|
||||
run: |
|
||||
mkdir release_temp
|
||||
# Export source using git archive (respects .gitattributes)
|
||||
git archive --format=tar HEAD | tar -x -C release_temp
|
||||
|
||||
- name: Install Development Dependencies (for Build & AI)
|
||||
run: npm install
|
||||
|
||||
- name: Build Localized Assets & Editor Bundle
|
||||
run: |
|
||||
npm run sync:assets
|
||||
npm run build:editor
|
||||
|
||||
- name: Generate AI Release Notes
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
run: |
|
||||
node .github/scripts/generate-release-notes.js ${{ github.ref_name }}
|
||||
|
||||
- name: Install Production Dependencies
|
||||
run: |
|
||||
cd release_temp
|
||||
composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs
|
||||
|
||||
- name: Copy Build Artifacts to Release
|
||||
run: |
|
||||
cp -r public/assets/vendor/ release_temp/public/assets/
|
||||
mkdir -p release_temp/public/assets/js/vendor/
|
||||
cp public/assets/js/vendor/editor.bundle.js release_temp/public/assets/js/vendor/
|
||||
|
||||
- name: Build Zip Artifact
|
||||
run: |
|
||||
cd release_temp
|
||||
zip -r ../mivo-v${{ steps.get_version.outputs.VERSION }}.zip .
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: mivo-v${{ steps.get_version.outputs.VERSION }}.zip
|
||||
body_path: .github/release_notes.md
|
||||
generate_release_notes: false
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -17,14 +17,20 @@ Thumbs.db
|
||||
# Build Artifacts & Deployments
|
||||
/deploy_package.tar.gz
|
||||
/mivo_backup_*.mivo
|
||||
/mivo-*.zip
|
||||
|
||||
# Secrets and Environment
|
||||
.env
|
||||
|
||||
# VitePress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
# Build Scripts & Artifacts
|
||||
build_release.ps1
|
||||
*.zip
|
||||
deploy.ps1
|
||||
.github/release_notes.md
|
||||
|
||||
# User Uploads
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitignore
|
||||
|
||||
# Plugins
|
||||
/plugins/*
|
||||
!/plugins/.gitkeep
|
||||
@@ -1,12 +1,12 @@
|
||||
<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>
|
||||
|
||||
# MIVO (Mikrotik Voucher) Docker Image
|
||||
|
||||
> **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.
|
||||
|
||||
@@ -24,7 +24,7 @@ docker run -d \
|
||||
-e APP_ENV=production \
|
||||
-v mivo_data:/var/www/html/app/Database \
|
||||
-v mivo_config:/var/www/html/.env \
|
||||
dyzulk/mivo:latest
|
||||
mivodev/mivo:latest
|
||||
```
|
||||
|
||||
Open your browser and navigate to `http://localhost:8080`.
|
||||
@@ -39,7 +39,7 @@ For a more permanent setup, use `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
mivo:
|
||||
image: dyzulk/mivo:latest
|
||||
image: mivodev/mivo:latest
|
||||
container_name: mivo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -57,6 +57,37 @@ services:
|
||||
- `edge`: Bleeding edge build from the `main` branch.
|
||||
- `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
|
||||
|
||||
| Variable | Description | Default |
|
||||
@@ -80,4 +111,4 @@ If you find MIVO useful, please consider supporting its development. Your contri
|
||||
[](https://sociabuzz.com/dyzulkdev/tribe)
|
||||
|
||||
---
|
||||
*Created by DyzulkDev*
|
||||
*Created by MivoDev*
|
||||
|
||||
@@ -29,8 +29,15 @@ RUN mkdir -p /var/www/html/app/Database && \
|
||||
chown -R www-data:www-data /var/www/html && \
|
||||
chmod -R 755 /var/www/html
|
||||
|
||||
# Copy Entrypoint
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start Supervisor (which starts Nginx & PHP-FPM)
|
||||
# Use Entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
# Start Supervisor
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
|
||||
10
README.md
10
README.md
@@ -6,7 +6,7 @@
|
||||
|
||||
> **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,15 +30,15 @@ MIVO is a complete rewrite of the legendary **Mikhmon v3**, re-engineered with a
|
||||
|
||||
1. **Install via Composer**
|
||||
```bash
|
||||
composer create-project dyzulk/mivo
|
||||
composer create-project mivodev/mivo
|
||||
cd mivo
|
||||
```
|
||||
|
||||
> **Alternative (Docker):**
|
||||
> ```bash
|
||||
> docker pull dyzulk/mivo
|
||||
> docker pull mivodev/mivo
|
||||
> ```
|
||||
> *See [INSTALLATION.md](docs/INSTALLATION.md) for more tags.*
|
||||
> *See [DOCKER_README.md](DOCKER_README.md) for more tags.*
|
||||
|
||||
2. **Setup Environment**
|
||||
```bash
|
||||
@@ -83,4 +83,4 @@ If you find MIVO useful, please consider supporting its development. Your contri
|
||||
This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
---
|
||||
*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,17 +3,17 @@ namespace App\Config;
|
||||
|
||||
class SiteConfig {
|
||||
const APP_NAME = 'MIVO';
|
||||
const APP_VERSION = 'v1.0';
|
||||
const APP_VERSION = 'v1.2.3';
|
||||
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
||||
const CREDIT_NAME = 'DyzulkDev';
|
||||
const CREDIT_URL = 'https://dyzulk.com';
|
||||
const CREDIT_NAME = 'MivoDev';
|
||||
const CREDIT_URL = 'https://github.com/mivodev';
|
||||
const YEAR = '2026';
|
||||
const REPO_URL = 'https://github.com/dyzulk/mivo';
|
||||
const REPO_URL = 'https://github.com/mivodev/mivo';
|
||||
|
||||
// Security Keys
|
||||
// Fetched from .env or fallback to default
|
||||
public static function getSecretKey() {
|
||||
return getenv('APP_KEY') ?: 'mikhmonv3remake_secret_key_32bytes';
|
||||
return getenv('APP_KEY') ?: 'mivo_official_secret_key_32bytes';
|
||||
}
|
||||
|
||||
const IS_DEV = true; // Still useful for code logic not relying on env yet, or can be refactored too.
|
||||
|
||||
@@ -32,7 +32,8 @@ class ApiController extends Controller {
|
||||
$configModel = new Config();
|
||||
$session = $configModel->getSessionById($id);
|
||||
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 {
|
||||
|
||||
public function __construct() {
|
||||
Middleware::auth();
|
||||
// Auth handled by Router Middleware
|
||||
}
|
||||
|
||||
public function index($session) {
|
||||
@@ -101,6 +101,7 @@ class DashboardController extends Controller {
|
||||
'hotspot_users' => 'Hotspot Users',
|
||||
'hotspot_users' => 'Hotspot Users',
|
||||
],
|
||||
'reload_interval' => $creds['reload'] ?? 5, // Default 5s if not set
|
||||
'interface' => $creds['interface'] ?? 'ether1'
|
||||
];
|
||||
// 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);
|
||||
|
||||
} 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'])) {
|
||||
// Fetch DHCP Leases
|
||||
$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
|
||||
|
||||
@@ -34,8 +34,9 @@ class GeneratorController extends Controller {
|
||||
|
||||
$this->view('hotspot/generate', $data);
|
||||
} else {
|
||||
// Handle connection error (flash message ideally, but for now redirect or show error)
|
||||
echo "Connection failed to " . $creds['ip'];
|
||||
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class HotspotController extends Controller {
|
||||
|
||||
$userId = $session; // For view context
|
||||
$users = [];
|
||||
$servers = [];
|
||||
$error = null;
|
||||
|
||||
$API = new RouterOSAPI();
|
||||
@@ -40,17 +41,20 @@ class HotspotController extends Controller {
|
||||
// Get all hotspot users
|
||||
$users = $API->comm("/ip/hotspot/user/print");
|
||||
|
||||
// Get active users to mark status (optional, can be done later for optimization)
|
||||
// $active = $API->comm("/ip/hotspot/active/print");
|
||||
// Get servers for dropdown
|
||||
$servers = $API->comm("/ip/hotspot/server/print");
|
||||
|
||||
$API->disconnect();
|
||||
} 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 = [
|
||||
'session' => $session,
|
||||
'users' => $users,
|
||||
'servers' => $servers,
|
||||
'error' => $error
|
||||
];
|
||||
|
||||
@@ -389,7 +393,9 @@ class HotspotController extends Controller {
|
||||
$items = $API->comm("/ip/hotspot/active/print");
|
||||
$API->disconnect();
|
||||
} 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 = [
|
||||
@@ -451,7 +457,9 @@ class HotspotController extends Controller {
|
||||
$items = $API->comm("/ip/hotspot/host/print");
|
||||
$API->disconnect();
|
||||
} 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 = [
|
||||
@@ -484,7 +492,9 @@ class HotspotController extends Controller {
|
||||
$items = $API->comm("/ip/hotspot/ip-binding/print");
|
||||
$API->disconnect();
|
||||
} 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 = [
|
||||
@@ -606,7 +616,9 @@ class HotspotController extends Controller {
|
||||
$items = $API->comm("/ip/hotspot/walled-garden/ip/print");
|
||||
$API->disconnect();
|
||||
} 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 = [
|
||||
@@ -837,8 +849,9 @@ class HotspotController extends Controller {
|
||||
$templateContent = $tpl['content'];
|
||||
$viewName = 'print/custom';
|
||||
} else {
|
||||
// Fallback if ID invalid
|
||||
$currentTemplate = 'default';
|
||||
\App\Helpers\FlashHelper::set('error', 'Template Not Found', 'The selected print template could not be found.');
|
||||
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/hotspot/users'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ class InstallController extends Controller {
|
||||
exit;
|
||||
}
|
||||
|
||||
return $this->view('install');
|
||||
$permissions = $this->checkPermissions();
|
||||
|
||||
return $this->view('install', [
|
||||
'permissions' => $permissions
|
||||
]);
|
||||
}
|
||||
|
||||
public function process() {
|
||||
@@ -25,6 +29,13 @@ class InstallController extends Controller {
|
||||
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';
|
||||
$password = $_POST['password'] ?? 'admin';
|
||||
|
||||
@@ -33,7 +44,7 @@ class InstallController extends Controller {
|
||||
Migrations::up();
|
||||
|
||||
// 2. Generate Key if default
|
||||
if (SiteConfig::getSecretKey() === 'mikhmonv3remake_secret_key_32bytes') {
|
||||
if (SiteConfig::getSecretKey() === 'mivo_official_secret_key_32bytes') {
|
||||
$this->generateKey();
|
||||
}
|
||||
|
||||
@@ -63,16 +74,27 @@ class InstallController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private function checkPermissions() {
|
||||
$dbDir = ROOT . '/app/Database';
|
||||
$envFile = ROOT . '/.env';
|
||||
|
||||
return [
|
||||
'db_writable' => is_writable($dbDir),
|
||||
'env_writable' => file_exists($envFile) ? is_writable($envFile) : is_writable(ROOT),
|
||||
'root_writable' => is_writable(ROOT)
|
||||
];
|
||||
}
|
||||
|
||||
private function isInstalled() {
|
||||
// Check if .env exists and APP_KEY is set to something other than the default/example
|
||||
$envPath = ROOT . '/.env';
|
||||
if (!file_exists($envPath)) {
|
||||
// Check if SiteConfig has a manual override (legacy)
|
||||
return SiteConfig::getSecretKey() !== 'mikhmonv3remake_secret_key_32bytes';
|
||||
return SiteConfig::getSecretKey() !== 'mivo_official_secret_key_32bytes';
|
||||
}
|
||||
|
||||
$key = getenv('APP_KEY');
|
||||
$keyChanged = ($key && $key !== 'mikhmonv3remake_secret_key_32bytes');
|
||||
$keyChanged = ($key && $key !== 'mivo_official_secret_key_32bytes');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
@@ -44,7 +44,10 @@ class LogController extends Controller
|
||||
$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', [
|
||||
|
||||
@@ -21,6 +21,21 @@ class ProfileController extends Controller
|
||||
// Use default port 8728 if not specified
|
||||
if ($API->connect($creds['ip'], $creds['user'], $creds['password'])) {
|
||||
$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();
|
||||
|
||||
// Process profiles to add metadata from on-login script
|
||||
@@ -33,15 +48,14 @@ class ProfileController extends Controller
|
||||
$this->view('hotspot/profiles/index', [
|
||||
'session' => $session,
|
||||
'profiles' => $profiles,
|
||||
'pools' => $pools,
|
||||
'queues' => $queues,
|
||||
'title' => 'User Profiles'
|
||||
]);
|
||||
} else {
|
||||
$this->view('hotspot/profiles/index', [
|
||||
'session' => $session,
|
||||
'profiles' => [],
|
||||
'error' => 'Connection Failed to ' . $creds['ip'],
|
||||
'title' => 'User Profiles'
|
||||
]);
|
||||
\App\Helpers\FlashHelper::set('error', 'Connection Failed', 'Could not connect to router at ' . $creds['ip']);
|
||||
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/' . $session . '/dashboard'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,10 @@ class PublicStatusController extends Controller {
|
||||
// View: Show Search Page
|
||||
public function index($session) {
|
||||
// Just verify session existence to display Hotspot Name
|
||||
// Session verified by RouterCheckMiddleware
|
||||
$configModel = new Config();
|
||||
$creds = $configModel->getSession($session);
|
||||
|
||||
if (!$creds) {
|
||||
// If session invalid, maybe show 404 or generic error
|
||||
echo "Session not found.";
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'session' => $session,
|
||||
'hotspot_name' => $creds['hotspot_name'] ?? 'Hotspot',
|
||||
@@ -92,27 +87,31 @@ class PublicStatusController extends Controller {
|
||||
if (!empty($user)) {
|
||||
$u = $user[0];
|
||||
|
||||
// DEBUG: Log the user data to see raw values
|
||||
error_log("Status Debug: " . json_encode($u));
|
||||
|
||||
// --- SECURITY CHECK: Hide Unused Vouchers ---
|
||||
// --- SECURITY CHECK: Hide Unused Vouchers (UNLESS ACTIVE) ---
|
||||
$uptimeRaw = $u['uptime'] ?? '0s';
|
||||
$bytesIn = intval($u['bytes-in'] ?? 0);
|
||||
$bytesOut = intval($u['bytes-out'] ?? 0);
|
||||
|
||||
if (($uptimeRaw === '0s' || empty($uptimeRaw)) && ($bytesIn + $bytesOut) === 0) {
|
||||
// Check if active first
|
||||
$active = $api->comm("/ip/hotspot/active/print", [
|
||||
"?user" => $code
|
||||
]);
|
||||
$isActive = !empty($active);
|
||||
|
||||
// If Empty Stats AND Not Active => Hide (It's an unused new voucher)
|
||||
// If Empty Stats BUT Active => Show! (It's a fresh session)
|
||||
if (!$isActive && ($uptimeRaw === '0s' || empty($uptimeRaw)) && ($bytesIn + $bytesOut) === 0) {
|
||||
$api->disconnect();
|
||||
echo json_encode(['success' => false, 'message' => 'Voucher Not Found']);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- SECURITY CHECK: Hide Unlimited Members ---
|
||||
// --- SECURITY CHECK: Hide Unlimited Members (UNLESS ACTIVE) ---
|
||||
$limitBytes = isset($u['limit-bytes-total']) ? intval($u['limit-bytes-total']) : 0;
|
||||
$limitUptime = $u['limit-uptime'] ?? '0s';
|
||||
|
||||
if ($limitBytes === 0 && ($limitUptime === '0s' || empty($limitUptime))) {
|
||||
// Option: Allow checking them but show minimalistic info, or hide.
|
||||
// Sticking to original logic: Hide them.
|
||||
if (!$isActive && $limitBytes === 0 && ($limitUptime === '0s' || empty($limitUptime))) {
|
||||
// Hide unlimited members if they are offline to prevent enumeration
|
||||
$api->disconnect();
|
||||
echo json_encode(['success' => false, 'message' => 'Voucher Not Found']);
|
||||
return;
|
||||
@@ -181,11 +180,9 @@ class PublicStatusController extends Controller {
|
||||
// 2. CHECK ACTIVE OVERRIDE
|
||||
// If user is conceptually valid (or even if limited?), check if they are currently active
|
||||
// Because they might be active BUT expiring soon, or active BUT over quota (if server hasn't kicked them yet)
|
||||
$active = $api->comm("/ip/hotspot/active/print", [
|
||||
"?user" => $code
|
||||
]);
|
||||
// $active already fetched above in Security Check
|
||||
|
||||
if (!empty($active)) {
|
||||
if ($isActive) {
|
||||
$status = 'active';
|
||||
$statusLabel = 'Active (Online)';
|
||||
}
|
||||
|
||||
@@ -19,7 +19,14 @@ class QuickPrintController extends Controller {
|
||||
// Dashboard: List Cards
|
||||
public function index($session) {
|
||||
$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 = [
|
||||
'session' => $session,
|
||||
@@ -32,11 +39,12 @@ class QuickPrintController extends Controller {
|
||||
// List/Manage Packages (CRUD)
|
||||
public function manage($session) {
|
||||
$qpModel = new QuickPrintModel();
|
||||
$packages = $qpModel->getAllBySession($session);
|
||||
|
||||
// Need profiles for the Add/Edit Modal
|
||||
$configModel = new Config();
|
||||
$creds = $configModel->getSession($session);
|
||||
$routerId = $creds['id'] ?? null;
|
||||
|
||||
$packages = $routerId ? $qpModel->getAllByRouterId($routerId) : [];
|
||||
$profiles = [];
|
||||
if ($creds) {
|
||||
$API = new RouterOSAPI();
|
||||
@@ -63,7 +71,13 @@ class QuickPrintController extends Controller {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||
|
||||
$session = $_POST['session'] ?? '';
|
||||
|
||||
$configModel = new Config();
|
||||
$creds = $configModel->getSession($session);
|
||||
$routerId = $creds['id'] ?? 0;
|
||||
|
||||
$data = [
|
||||
'router_id' => $routerId,
|
||||
'session_name' => $session,
|
||||
'name' => $_POST['name'] ?? 'Package',
|
||||
'server' => $_POST['server'] ?? 'all',
|
||||
@@ -71,6 +85,7 @@ class QuickPrintController extends Controller {
|
||||
'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'] ?? '',
|
||||
@@ -85,6 +100,40 @@ class QuickPrintController extends Controller {
|
||||
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
|
||||
public function delete() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||
@@ -142,10 +191,10 @@ class QuickPrintController extends Controller {
|
||||
// Check if M or G
|
||||
// Simple logic for now, assuming raw if number, or passing string if Mikrotik accepts it (usually requires bytes)
|
||||
// Let's assume user inputs "100M" or "1G" which usually needs parsing.
|
||||
// For now, let's assume input is NUMBER in MB as per standard Mikhmon practice, OR generic string.
|
||||
// For now, let's assume input is NUMBER in MB as per standard Mivo practice, OR generic string.
|
||||
// We'll pass as is for strings, or multiply if strictly numeric?
|
||||
// Let's rely on standard Mikrotik parsing if string passed, or convert.
|
||||
// Mikhmon v3 usually uses dropdown "MB/GB".
|
||||
// Mivo usually uses dropdown "MB/GB".
|
||||
// Implementing simple conversion:
|
||||
$val = intval($package['data_limit']);
|
||||
if (strpos(strtolower($package['data_limit']), 'g') !== false) {
|
||||
@@ -158,7 +207,9 @@ class QuickPrintController extends Controller {
|
||||
$API->comm("/ip/hotspot/user/add", $userData);
|
||||
$API->disconnect();
|
||||
} 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,22 +11,66 @@ class ReportController extends Controller
|
||||
{
|
||||
public function index($session)
|
||||
{
|
||||
$configModel = new Config();
|
||||
$config = $configModel->getSession($session);
|
||||
|
||||
if (!$config) {
|
||||
$data = $this->getSellingReportData($session);
|
||||
if (!$data) {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
return $this->view('reports/selling', $data);
|
||||
}
|
||||
|
||||
public function sellingExport($session, $type)
|
||||
{
|
||||
$data = $this->getSellingReportData($session);
|
||||
if (!$data) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'No data found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$report = $data['report'];
|
||||
$exportData = [];
|
||||
|
||||
foreach ($report as $row) {
|
||||
$exportData[] = [
|
||||
'Date/Batch' => $row['date'],
|
||||
'Status' => $row['status'] ?? '-',
|
||||
'Qty (Stock)' => $row['count'],
|
||||
'Used' => $row['realized_count'],
|
||||
'Realized Income' => $row['realized_total'],
|
||||
'Total Stock' => $row['total']
|
||||
];
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($exportData);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function getSellingReportData($session)
|
||||
{
|
||||
$configModel = new Config();
|
||||
$config = $configModel->getSession($session);
|
||||
|
||||
if (!$config) return null;
|
||||
|
||||
$API = new RouterOSAPI();
|
||||
$users = [];
|
||||
|
||||
$profilePriceMap = [];
|
||||
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
||||
// Fetch All Users
|
||||
// Optimized print: get .id, name, price, comment
|
||||
$users = $API->comm("/ip/hotspot/user/print");
|
||||
$profiles = $API->comm("/ip/hotspot/user/profile/print");
|
||||
$API->disconnect();
|
||||
|
||||
// Build Price Map from Profile Scripts
|
||||
foreach ($profiles as $p) {
|
||||
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
|
||||
if (!empty($meta['price'])) {
|
||||
$profilePriceMap[$p['name']] = intval($meta['price']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate Data
|
||||
@@ -34,21 +78,22 @@ class ReportController extends Controller
|
||||
$totalIncome = 0;
|
||||
$totalVouchers = 0;
|
||||
|
||||
// Realized (Used) Metrics
|
||||
$totalRealizedIncome = 0;
|
||||
$totalUsedVouchers = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
// Skip if no price
|
||||
if (empty($user['price']) || $user['price'] == '0') continue;
|
||||
// Smart Price Detection
|
||||
$price = $this->detectPrice($user, $profilePriceMap);
|
||||
if ($price <= 0) continue;
|
||||
|
||||
// Inject price back to user array for downstream logic
|
||||
$user['price'] = $price;
|
||||
|
||||
// Determine Date from Comment
|
||||
// Mikhmon format usually: "mikhmon-MM/DD/YYYY" or just "MM/DD/YYYY" or plain comment
|
||||
// We will try to parse a date from the comment, or use "Unknown Date"
|
||||
$date = 'Unknown Date';
|
||||
$comment = $user['comment'] ?? '';
|
||||
|
||||
// Regex for date patterns (d-m-Y or m/d/Y or Y-m-d)
|
||||
// Simplify: Group by Comment content itself if it looks like a date/batch
|
||||
// Or try to extract M-Y.
|
||||
|
||||
// For feature parity, Mikhmon often groups by the exact comment string as the "Batch/Date"
|
||||
if (!empty($comment)) {
|
||||
$date = $comment;
|
||||
}
|
||||
@@ -57,28 +102,59 @@ class ReportController extends Controller
|
||||
$report[$date] = [
|
||||
'date' => $date,
|
||||
'count' => 0,
|
||||
'total' => 0
|
||||
'total' => 0,
|
||||
'realized_total' => 0,
|
||||
'realized_count' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$price = intval($user['price']);
|
||||
|
||||
// Check if Used
|
||||
// Criteria: uptime != 0s OR bytes-out > 0 OR bytes-in > 0
|
||||
$isUsed = false;
|
||||
if ((isset($user['uptime']) && $user['uptime'] != '0s') ||
|
||||
(isset($user['bytes-out']) && $user['bytes-out'] > 0)) {
|
||||
$isUsed = true;
|
||||
}
|
||||
|
||||
$report[$date]['count']++;
|
||||
$report[$date]['total'] += $price;
|
||||
|
||||
$totalIncome += $price;
|
||||
$totalVouchers++;
|
||||
|
||||
if ($isUsed) {
|
||||
$report[$date]['realized_count']++;
|
||||
$report[$date]['realized_total'] += $price;
|
||||
$totalRealizedIncome += $price;
|
||||
$totalUsedVouchers++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Status for each batch
|
||||
foreach ($report as &$row) {
|
||||
if ($row['realized_count'] === 0) {
|
||||
$row['status'] = 'New';
|
||||
} elseif ($row['realized_count'] >= $row['count']) {
|
||||
$row['status'] = 'Sold Out';
|
||||
} else {
|
||||
$row['status'] = 'Selling';
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// Sort by key (Date/Comment) desc
|
||||
krsort($report);
|
||||
|
||||
return $this->view('reports/selling', [
|
||||
return [
|
||||
'session' => $session,
|
||||
'report' => $report,
|
||||
'totalIncome' => $totalIncome,
|
||||
'totalVouchers' => $totalVouchers,
|
||||
'totalRealizedIncome' => $totalRealizedIncome,
|
||||
'totalUsedVouchers' => $totalUsedVouchers,
|
||||
'currency' => $config['currency'] ?? 'Rp'
|
||||
]);
|
||||
];
|
||||
}
|
||||
public function resume($session)
|
||||
{
|
||||
@@ -93,9 +169,18 @@ class ReportController extends Controller
|
||||
$API = new RouterOSAPI();
|
||||
$users = [];
|
||||
|
||||
$profilePriceMap = [];
|
||||
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
||||
$users = $API->comm("/ip/hotspot/user/print");
|
||||
$profiles = $API->comm("/ip/hotspot/user/profile/print");
|
||||
$API->disconnect();
|
||||
|
||||
foreach ($profiles as $p) {
|
||||
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
|
||||
if (!empty($meta['price'])) {
|
||||
$profilePriceMap[$p['name']] = intval($meta['price']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Aggregates
|
||||
@@ -104,27 +189,68 @@ class ReportController extends Controller
|
||||
$yearly = [];
|
||||
$totalIncome = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
if (empty($user['price']) || $user['price'] == '0') continue;
|
||||
// Realized Metrics for Resume?
|
||||
// Usually Resume is just general financial overview.
|
||||
// We'll stick to Stock for now unless requested, as Resume mimics Mikhmon's logic closer.
|
||||
// Or we can just calculate standard revenue based on Stock if that's what user expects for "Resume",
|
||||
// OR we can add Realized. Let's keep Resume simple first, focus on Selling Report.
|
||||
|
||||
// Try to parse Date from Comment (Mikhmon format: mikhmon-10/25/2023 or just 10/25/2023)
|
||||
foreach ($users as $user) {
|
||||
$price = $this->detectPrice($user, $profilePriceMap);
|
||||
if ($price <= 0) continue;
|
||||
|
||||
$user['price'] = $price;
|
||||
|
||||
// Try to parse Date from Comment
|
||||
// Supported formats:
|
||||
// - MM/DD/YYYY or MM.DD.YYYY (US)
|
||||
// - DD-MM-YYYY (EU/ID)
|
||||
// - YYYY-MM-DD (ISO)
|
||||
// Regex explanations:
|
||||
// 1. \b starts word boundary to avoid matching parts of batch IDs (e.g. 711-...)
|
||||
// 2. We look for 3 groups of digits separated by / . or -
|
||||
$comment = $user['comment'] ?? '';
|
||||
$dateObj = null;
|
||||
|
||||
// Simple parser: try to find MM/DD/YYYY
|
||||
if (preg_match('/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})/', $comment, $matches)) {
|
||||
// Assuming MM/DD/YYYY based on typical Mikhmon, but could be DD-MM-YYYY
|
||||
// Let's standardise on checking valid date.
|
||||
// Standard Mikhmon V3 is MM/DD/YYYY.
|
||||
$m = $matches[1];
|
||||
$d = $matches[2];
|
||||
$y = $matches[3];
|
||||
if (strlen($y) == 2) $y = '20' . $y;
|
||||
$dateObj = \DateTime::createFromFormat('m/d/Y', "$m/$d/$y");
|
||||
if (preg_match('/\b(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})\b/', $comment, $matches)) {
|
||||
// Heuristic: If 3rd part is year (4 digits or > 31), use it.
|
||||
// If 1st part > 12, it's likely Day (DD-MM-YYYY).
|
||||
// Mivo Generator format often: MM.DD.YY or DD.MM.YY
|
||||
|
||||
$p1 = intval($matches[1]);
|
||||
$p2 = intval($matches[2]);
|
||||
$p3 = intval($matches[3]);
|
||||
|
||||
$year = $p3;
|
||||
$month = $p1;
|
||||
$day = $p2;
|
||||
|
||||
// Adjust 2-digit year
|
||||
if ($year < 100) $year += 2000;
|
||||
|
||||
// Guess format
|
||||
// If p1 > 12, it must be Day. (DD-MM-YYYY)
|
||||
if ($p1 > 12) {
|
||||
$day = $p1;
|
||||
$month = $p2;
|
||||
}
|
||||
|
||||
// Fallback: If no date found in comment, maybe created at?
|
||||
// Usually Mikhmon relies strictly on comment.
|
||||
// Validate date
|
||||
if (checkdate($month, $day, $year)) {
|
||||
$dateObj = (new \DateTime())->setDate($year, $month, $day);
|
||||
}
|
||||
}
|
||||
// Check for ISO YYYY-MM-DD
|
||||
elseif (preg_match('/\b(\d{4})[\/.-](\d{1,2})[\/.-](\d{1,2})\b/', $comment, $matches)) {
|
||||
if (checkdate($matches[2], $matches[3], $matches[1])) {
|
||||
$dateObj = (new \DateTime())->setDate($matches[1], $matches[2], $matches[3]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If no date found -> "Unknown Date" in resume?
|
||||
// Resume requires Month/Year keys. If we can't parse date, we can't add to daily/monthly.
|
||||
// We'll skip or add to "Unknown"?
|
||||
// Current logic skips if !$dateObj
|
||||
if (!$dateObj) continue;
|
||||
|
||||
$price = intval($user['price']);
|
||||
@@ -162,4 +288,38 @@ class ReportController extends Controller
|
||||
'currency' => $config['currency'] ?? 'Rp'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart Price Detection Logic
|
||||
* Hierarchy:
|
||||
* 1. Comment Override (p:5000)
|
||||
* 2. Profile Script (Standard Profile)
|
||||
* 3. Profile Name Fallback (50K) -- REMOVED loose number matching to avoid garbage data
|
||||
*/
|
||||
private function detectPrice($user, $profileMap)
|
||||
{
|
||||
$comment = $user['comment'] ?? '';
|
||||
|
||||
// 1. Comment Override (p:5000 or price:5000)
|
||||
// Updated: Added \b to prevent matching "up-123" as "p-123"
|
||||
if (preg_match('/\b(?:p|price)[:-]\s*(\d+)/i', $comment, $matches)) {
|
||||
return intval($matches[1]);
|
||||
}
|
||||
|
||||
// 2. Profile Script
|
||||
$profile = $user['profile'] ?? 'default';
|
||||
if (isset($profileMap[$profile])) {
|
||||
return $profileMap[$profile];
|
||||
}
|
||||
|
||||
// 3. Fallback: Parse Profile Name (Strict "K" notation only)
|
||||
// Matches "5K", "5k" -> 5000
|
||||
if (preg_match('/(\d+)k\b/i', $profile, $m)) {
|
||||
return intval($m[1]) * 1000;
|
||||
}
|
||||
|
||||
// DEPRECATED: Loose number matching caused garbage data (e.g. "up-311" -> 311)
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use App\Helpers\FormatHelper;
|
||||
class SettingsController extends Controller {
|
||||
|
||||
public function __construct() {
|
||||
Middleware::auth();
|
||||
// Auth handled by Router Middleware
|
||||
}
|
||||
|
||||
public function system() {
|
||||
@@ -33,10 +33,6 @@ class SettingsController extends Controller {
|
||||
return $this->view('settings/index', ['routers' => $routers]);
|
||||
}
|
||||
|
||||
public function add() {
|
||||
return $this->view('settings/form');
|
||||
}
|
||||
|
||||
// ... (Existing Store methods) ...
|
||||
public function store() {
|
||||
// Sanitize Session Name (Duplicate Frontend Logic)
|
||||
@@ -81,7 +77,7 @@ class SettingsController extends Controller {
|
||||
$db = \App\Core\Database::getInstance();
|
||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
// Assuming we are updating the default 'admin' user or the currently logged in user
|
||||
// Original Mikhmon usually has one main user. Let's update 'admin' for now.
|
||||
// Original Mivo usually has one main user. Let's update 'admin' for now.
|
||||
$db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]);
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true);
|
||||
}
|
||||
@@ -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() {
|
||||
$id = $_POST['id'];
|
||||
@@ -316,7 +286,7 @@ class SettingsController extends Controller {
|
||||
// Restore Logos
|
||||
if (isset($json['logos'])) {
|
||||
$logoModel = new \App\Models\Logo();
|
||||
$uploadDir = ROOT . '/public/assets/img/logos/';
|
||||
$uploadDir = ROOT . '/public/uploads/logos/';
|
||||
if (!file_exists($uploadDir)) {
|
||||
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", [
|
||||
'id' => $logo['id'],
|
||||
'name' => $logo['name'],
|
||||
'path' => '/assets/img/logos/' . $filename,
|
||||
'path' => '/uploads/logos/' . $filename,
|
||||
'type' => $extension,
|
||||
'size' => $logo['size']
|
||||
]);
|
||||
@@ -371,22 +341,24 @@ class SettingsController extends Controller {
|
||||
}
|
||||
|
||||
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');
|
||||
exit;
|
||||
}
|
||||
|
||||
$logoModel = new \App\Models\Logo();
|
||||
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) {
|
||||
// Ideally flash error message to session
|
||||
// For now, redirect (logging error via debug or ignoring as per simple req)
|
||||
// session_start() is implicit in Middleware usually or index
|
||||
// $_SESSION['error'] = $e->getMessage();
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', $e->getMessage(), [], true);
|
||||
}
|
||||
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.logo_uploaded', 'toasts.logo_uploaded_desc', [], true);
|
||||
header('Location: /settings/logos');
|
||||
}
|
||||
|
||||
@@ -459,4 +431,194 @@ class SettingsController extends Controller {
|
||||
}
|
||||
header('Location: /settings/api-cors');
|
||||
}
|
||||
|
||||
// --- Plugin Management ---
|
||||
|
||||
public function plugins() {
|
||||
$pluginManager = new \App\Core\PluginManager();
|
||||
// Since PluginManager loads everything in constructor/loadPlugins,
|
||||
// we can just scan the directory to list them and check status (implied active for now)
|
||||
$pluginsDir = ROOT . '/plugins';
|
||||
$plugins = [];
|
||||
|
||||
if (is_dir($pluginsDir)) {
|
||||
$folders = scandir($pluginsDir);
|
||||
foreach ($folders as $folder) {
|
||||
if ($folder === '.' || $folder === '..') continue;
|
||||
|
||||
$path = $pluginsDir . '/' . $folder;
|
||||
if (is_dir($path) && file_exists($path . '/plugin.php')) {
|
||||
// Try to read header from plugin.php
|
||||
$content = file_get_contents($path . '/plugin.php', false, null, 0, 1024); // Read first 1KB
|
||||
preg_match('/Plugin Name:\s*(.*)$/mi', $content, $nameMatch);
|
||||
preg_match('/Version:\s*(.*)$/mi', $content, $verMatch);
|
||||
preg_match('/Description:\s*(.*)$/mi', $content, $descMatch);
|
||||
preg_match('/Author:\s*(.*)$/mi', $content, $authMatch);
|
||||
|
||||
$plugins[] = [
|
||||
'id' => $folder,
|
||||
'name' => trim($nameMatch[1] ?? $folder),
|
||||
'version' => trim($verMatch[1] ?? '1.0.0'),
|
||||
'description' => trim($descMatch[1] ?? '-'),
|
||||
'author' => trim($authMatch[1] ?? '-'),
|
||||
'path' => $path
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->view('settings/plugins', ['plugins' => $plugins]);
|
||||
}
|
||||
|
||||
public function uploadPlugin() {
|
||||
if (!isset($_FILES['plugin_file']) || $_FILES['plugin_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['plugin_file'];
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext !== 'zip') {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Only .zip files are allowed', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($file['tmp_name']) === TRUE) {
|
||||
$extractPath = ROOT . '/plugins/';
|
||||
if (!is_dir($extractPath)) mkdir($extractPath, 0755, true);
|
||||
|
||||
// TODO: Better validation to prevent overwriting existing plugins without confirmation?
|
||||
// For now, extraction overwrites.
|
||||
|
||||
// Validate content before extracting everything
|
||||
// Check if zip has a root folder or just files
|
||||
// Logic:
|
||||
// 1. Extract to temp.
|
||||
// 2. Find plugin.php
|
||||
// 3. Move to plugins dir.
|
||||
|
||||
$tempExtract = sys_get_temp_dir() . '/mivo_plugin_' . uniqid();
|
||||
if (!mkdir($tempExtract, 0755, true)) {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Failed to create temp dir', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$zip->extractTo($tempExtract);
|
||||
$zip->close();
|
||||
|
||||
// Search for plugin.php
|
||||
$pluginFile = null;
|
||||
$pluginRoot = $tempExtract;
|
||||
|
||||
// Recursive iterator to find plugin.php (max depth 2 to avoid deep scanning)
|
||||
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tempExtract));
|
||||
foreach ($rii as $file) {
|
||||
if ($file->isDir()) continue;
|
||||
if ($file->getFilename() === 'plugin.php') {
|
||||
$pluginFile = $file->getPathname();
|
||||
$pluginRoot = dirname($pluginFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($pluginFile) {
|
||||
// Determine destination name
|
||||
// If the immediate parent of plugin.php is NOT the temp dir, use that folder name.
|
||||
// Else use the zip name.
|
||||
$folderName = basename($pluginRoot);
|
||||
if ($pluginRoot === $tempExtract) {
|
||||
$folderName = pathinfo($_FILES['plugin_file']['name'], PATHINFO_FILENAME);
|
||||
}
|
||||
|
||||
$dest = $extractPath . $folderName;
|
||||
|
||||
// Move/Copy
|
||||
// Using helper or rename. Rename might fail across volumes (temp to project).
|
||||
// Use custom recursive copy then delete temp.
|
||||
$this->recurseCopy($pluginRoot, $dest);
|
||||
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_installed', 'toasts.plugin_installed_desc', ['name' => $folderName], true);
|
||||
} else {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.install_failed', 'toasts.invalid_plugin_desc', [], true);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
$this->recurseDelete($tempExtract);
|
||||
|
||||
} else {
|
||||
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.zip_open_failed_desc', [], true);
|
||||
}
|
||||
|
||||
header('Location: /settings/plugins');
|
||||
}
|
||||
|
||||
public function deletePlugin() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_POST['plugin_id'] ?? '';
|
||||
if (empty($id)) {
|
||||
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Security check: validate id is just a folder name, no path traversal
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $id)) {
|
||||
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID format', [], true);
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
$pluginDir = ROOT . '/plugins/' . $id;
|
||||
|
||||
if (is_dir($pluginDir)) {
|
||||
$this->recurseDelete($pluginDir);
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_deleted', 'toasts.plugin_deleted_desc', [], true);
|
||||
} else {
|
||||
\App\Helpers\FlashHelper::set('error', 'common.error', 'Plugin directory not found', [], true);
|
||||
}
|
||||
|
||||
header('Location: /settings/plugins');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Helper for recursive copy (since rename/move_uploaded_file limit across partitions)
|
||||
private function recurseCopy($src, $dst) {
|
||||
$dir = opendir($src);
|
||||
@mkdir($dst);
|
||||
while(false !== ( $file = readdir($dir)) ) {
|
||||
if (( $file != '.' ) && ( $file != '..' )) {
|
||||
if ( is_dir($src . '/' . $file) ) {
|
||||
$this->recurseCopy($src . '/' . $file,$dst . '/' . $file);
|
||||
}
|
||||
else {
|
||||
copy($src . '/' . $file,$dst . '/' . $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir($dir);
|
||||
}
|
||||
|
||||
private function recurseDelete($dir) {
|
||||
if (!is_dir($dir)) return;
|
||||
$scan = scandir($dir);
|
||||
foreach ($scan as $file) {
|
||||
if ($file == '.' || $file == '..') continue;
|
||||
if (is_dir($dir . "/" . $file)) {
|
||||
$this->recurseDelete($dir . "/" . $file);
|
||||
} else {
|
||||
unlink($dir . "/" . $file);
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Core\Controller;
|
||||
use App\Models\VoucherTemplateModel;
|
||||
use App\Core\Middleware;
|
||||
|
||||
class TemplateController extends Controller {
|
||||
class VoucherTemplateController extends Controller {
|
||||
|
||||
public function __construct() {
|
||||
Middleware::auth();
|
||||
@@ -19,7 +19,7 @@ class TemplateController extends Controller {
|
||||
$data = [
|
||||
'templates' => $templates
|
||||
];
|
||||
return $this->view('settings/templates/index', $data);
|
||||
return $this->view('settings/voucher_templates/index', $data);
|
||||
}
|
||||
|
||||
public function preview($id) {
|
||||
@@ -48,7 +48,7 @@ class TemplateController extends Controller {
|
||||
$data = [
|
||||
'logoMap' => $logoMap
|
||||
];
|
||||
return $this->view('settings/templates/add', $data); // Note: add.php likely includes edit.php or is alias. View above says 'Template Editor (Shared)'
|
||||
return $this->view('settings/voucher_templates/add', $data);
|
||||
}
|
||||
|
||||
public function store() {
|
||||
@@ -62,6 +62,7 @@ class TemplateController extends Controller {
|
||||
// I will use 'global' for templates created in Settings.
|
||||
|
||||
$data = [
|
||||
'router_id' => 0, // Global templates
|
||||
'session_name' => 'global',
|
||||
'name' => $name,
|
||||
'content' => $content
|
||||
@@ -71,7 +72,7 @@ class TemplateController extends Controller {
|
||||
$templateModel->add($data);
|
||||
|
||||
\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;
|
||||
}
|
||||
|
||||
@@ -80,7 +81,7 @@ class TemplateController extends Controller {
|
||||
$template = $templateModel->getById($id);
|
||||
|
||||
if (!$template) {
|
||||
header("Location: /settings/templates");
|
||||
header("Location: /settings/voucher-templates");
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ class TemplateController extends Controller {
|
||||
'template' => $template,
|
||||
'logoMap' => $logoMap
|
||||
];
|
||||
return $this->view('settings/templates/edit', $data);
|
||||
return $this->view('settings/voucher_templates/edit', $data);
|
||||
}
|
||||
|
||||
public function update() {
|
||||
@@ -114,7 +115,7 @@ class TemplateController extends Controller {
|
||||
$templateModel->update($id, $data);
|
||||
|
||||
\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;
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ class TemplateController extends Controller {
|
||||
$templateModel->delete($id);
|
||||
|
||||
\App\Helpers\FlashHelper::set('success', 'toasts.template_deleted', 'toasts.template_deleted_desc', [], true);
|
||||
header("Location: /settings/templates");
|
||||
header("Location: /settings/voucher-templates");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class Console {
|
||||
|
||||
private function printBanner() {
|
||||
echo "\n";
|
||||
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.0" . self::COLOR_RESET . "\n\n";
|
||||
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . \App\Config\SiteConfig::APP_VERSION . self::COLOR_RESET . "\n\n";
|
||||
}
|
||||
|
||||
private function commandServe($args) {
|
||||
@@ -171,7 +171,7 @@ class Console {
|
||||
|
||||
if (file_exists($envPath)) {
|
||||
$envIds = parse_ini_file($envPath);
|
||||
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mikhmonv3remake_secret_key_32bytes') {
|
||||
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mivo_official_secret_key_32bytes') {
|
||||
$keyExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS quick_prints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
router_id INTEGER,
|
||||
session_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
server TEXT NOT NULL,
|
||||
@@ -68,6 +69,7 @@ class Migrations {
|
||||
prefix TEXT DEFAULT '',
|
||||
char_length INTEGER DEFAULT 4,
|
||||
price INTEGER DEFAULT 0,
|
||||
selling_price INTEGER DEFAULT 0,
|
||||
time_limit TEXT DEFAULT '',
|
||||
data_limit TEXT DEFAULT '',
|
||||
comment TEXT DEFAULT '',
|
||||
@@ -79,6 +81,7 @@ class Migrations {
|
||||
// 7. Voucher Templates
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS voucher_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
router_id INTEGER,
|
||||
session_name TEXT NOT NULL,
|
||||
name 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,44 +4,156 @@ namespace App\Core;
|
||||
|
||||
class Router {
|
||||
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) {
|
||||
$this->routes['GET'][$path] = $callback;
|
||||
return $this->addRoute('GET', $path, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a POST route
|
||||
*/
|
||||
public function post($path, $callback) {
|
||||
$this->routes['POST'][$path] = $callback;
|
||||
return $this->addRoute('POST', $path, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a OPTIONS route (Crucial for CORS Preflight)
|
||||
*/
|
||||
public function options($path, $callback) {
|
||||
return $this->addRoute('OPTIONS', $path, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a PUT route
|
||||
*/
|
||||
public function put($path, $callback) {
|
||||
return $this->addRoute('PUT', $path, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a PATCH route
|
||||
*/
|
||||
public function patch($path, $callback) {
|
||||
return $this->addRoute('PATCH', $path, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a DELETE route
|
||||
*/
|
||||
public function delete($path, $callback) {
|
||||
return $this->addRoute('DELETE', $path, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add route to collection and return $this for chaining
|
||||
*/
|
||||
protected function addRoute($method, $path, $callback) {
|
||||
$path = $this->normalizePath($path);
|
||||
|
||||
$this->routes[$method][$path] = [
|
||||
'callback' => $callback,
|
||||
'middleware' => $this->currentGroupMiddleware // Inherit group middleware
|
||||
];
|
||||
|
||||
$this->lastRouteKey = ['method' => $method, 'path' => $path];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach middleware to the last defined route
|
||||
*/
|
||||
public function middleware($names) {
|
||||
if (!$this->lastRouteKey) return $this;
|
||||
|
||||
$method = $this->lastRouteKey['method'];
|
||||
$path = $this->lastRouteKey['path'];
|
||||
|
||||
$middlewares = is_array($names) ? $names : [$names];
|
||||
|
||||
// Merge with existing middleware (from groups)
|
||||
$this->routes[$method][$path]['middleware'] = array_merge(
|
||||
$this->routes[$method][$path]['middleware'],
|
||||
$middlewares
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a route group with shared attributes (middleware, prefix, etc.)
|
||||
*/
|
||||
public function group($attributes, callable $callback) {
|
||||
$previousGroupMiddleware = $this->currentGroupMiddleware;
|
||||
|
||||
if (isset($attributes['middleware'])) {
|
||||
$newMiddleware = is_array($attributes['middleware'])
|
||||
? $attributes['middleware']
|
||||
: [$attributes['middleware']];
|
||||
|
||||
$this->currentGroupMiddleware = array_merge(
|
||||
$this->currentGroupMiddleware,
|
||||
$newMiddleware
|
||||
);
|
||||
}
|
||||
|
||||
// Execute the callback with $this router instance
|
||||
$callback($this);
|
||||
|
||||
// Restore previous state
|
||||
$this->currentGroupMiddleware = $previousGroupMiddleware;
|
||||
}
|
||||
|
||||
protected function normalizePath($path) {
|
||||
return '/' . trim($path, '/');
|
||||
}
|
||||
|
||||
public function dispatch($uri, $method) {
|
||||
// Fire hook to allow plugins to register routes
|
||||
\App\Core\Hooks::doAction('router_init', $this);
|
||||
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
|
||||
// Handle subdirectory
|
||||
// Handle subdirectory (SKIP for PHP Built-in Server to avoid SCRIPT_NAME issues)
|
||||
if (php_sapi_name() !== 'cli-server') {
|
||||
$scriptName = dirname($_SERVER['SCRIPT_NAME']);
|
||||
if (strpos($path, $scriptName) === 0) {
|
||||
// Normalize backslashes (Windows)
|
||||
$scriptName = str_replace('\\', '/', $scriptName);
|
||||
|
||||
// Ensure we don't strip root slash
|
||||
if ($scriptName !== '/' && strpos($path, $scriptName) === 0) {
|
||||
$path = substr($path, strlen($scriptName));
|
||||
}
|
||||
$path = '/' . trim($path, '/');
|
||||
}
|
||||
$path = $this->normalizePath($path);
|
||||
|
||||
// Global Install Check: Redirect if database is missing
|
||||
// Global Install Check
|
||||
$dbPath = ROOT . '/app/Database/database.sqlite';
|
||||
if (!file_exists($dbPath)) {
|
||||
// Whitelist /install route and assets to prevent infinite loop
|
||||
if ($path !== '/install' && strpos($path, '/assets/') !== 0) {
|
||||
header('Location: /install');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Check exact match first
|
||||
// 1. Try Exact Match
|
||||
if (isset($this->routes[$method][$path])) {
|
||||
$callback = $this->routes[$method][$path];
|
||||
return $this->invokeCallback($callback);
|
||||
return $this->runRoute($this->routes[$method][$path], []);
|
||||
}
|
||||
|
||||
// Check dynamic routes
|
||||
foreach ($this->routes[$method] as $route => $callback) {
|
||||
// Convert route syntax to regex
|
||||
// 2. Try Dynamic Routes (Regex)
|
||||
foreach ($this->routes[$method] as $route => $config) {
|
||||
// e.g. /dashboard/{session} -> #^/dashboard/([^/]+)$#
|
||||
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $route);
|
||||
$pattern = "#^" . $pattern . "$#";
|
||||
@@ -49,13 +161,43 @@ class Router {
|
||||
if (preg_match($pattern, $path, $matches)) {
|
||||
array_shift($matches); // Remove full match
|
||||
$matches = array_map('urldecode', $matches);
|
||||
return $this->invokeCallback($callback, $matches);
|
||||
return $this->runRoute($config, $matches);
|
||||
}
|
||||
}
|
||||
|
||||
\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 = []) {
|
||||
if (is_array($callback)) {
|
||||
$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) {
|
||||
http_response_code($code);
|
||||
|
||||
// Provide default descriptions for common codes
|
||||
// Provide default translation keys for common codes
|
||||
if ($description === null) {
|
||||
switch ($code) {
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
case 404:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Helpers;
|
||||
class HotspotHelper
|
||||
{
|
||||
/**
|
||||
* Parse profile on-login script metadata (Mikhmon format)
|
||||
* Parse profile on-login script metadata (Standard format)
|
||||
* Format: :put (",mode,price,validity,selling_price,lock_user,")
|
||||
*/
|
||||
public static function parseProfileMetadata($script) {
|
||||
|
||||
@@ -40,6 +40,6 @@ class LanguageHelper
|
||||
}
|
||||
}
|
||||
|
||||
return $languages;
|
||||
return \App\Core\Hooks::applyFilters('get_available_languages', $languages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,13 @@ class TemplateHelper {
|
||||
'{{ip_address}}' => '192.168.88.254',
|
||||
'{{mac_address}}' => 'AA:BB:CC:DD:EE:FF',
|
||||
'{{comment}}' => 'Thank You',
|
||||
'{{copyright}}' => 'Mikhmon',
|
||||
'{{copyright}}' => 'Mivo',
|
||||
];
|
||||
|
||||
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content);
|
||||
|
||||
// QR Code replacement
|
||||
$content = preg_replace('/\{\{\s*qrcode.*?\}\}/i', '<img src="https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=http://hotspot.lan/login?user=u-5829" style="width:80px;height:80px;display:inline-block;">', $content);
|
||||
// QR Code replacement - Using canvas for client-side rendering with QRious
|
||||
$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;
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class TemplateHelper {
|
||||
body { display: flex; align-items: center; justify-content: center; background-color: transparent; }
|
||||
#wrapper { display: inline-block; transform-origin: center center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
</style>
|
||||
<script src="/assets/js/qrious.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrapper">' . $mockContent . '</div>
|
||||
@@ -77,6 +78,48 @@ class TemplateHelper {
|
||||
const wrap = document.getElementById("wrapper");
|
||||
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 w = wrap.offsetWidth;
|
||||
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) {
|
||||
return [
|
||||
'id' => $router['id'],
|
||||
'ip' => $router['ip_address'],
|
||||
'ip_address' => $router['ip_address'], // Alias
|
||||
'user' => $router['username'],
|
||||
|
||||
@@ -74,7 +74,7 @@ class Logo {
|
||||
$exists = $this->getById($id);
|
||||
} while ($exists);
|
||||
|
||||
$uploadDir = ROOT . '/public/assets/img/logos/';
|
||||
$uploadDir = ROOT . '/public/uploads/logos/';
|
||||
if (!file_exists($uploadDir)) {
|
||||
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)", [
|
||||
'id' => $id,
|
||||
'name' => $file['name'],
|
||||
'path' => '/assets/img/logos/' . $filename,
|
||||
'path' => '/uploads/logos/' . $filename,
|
||||
'type' => $extension,
|
||||
'size' => $file['size']
|
||||
]);
|
||||
@@ -98,17 +98,21 @@ class Logo {
|
||||
|
||||
public function syncFiles() {
|
||||
// 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;
|
||||
|
||||
$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) {
|
||||
$filename = basename($file);
|
||||
$extension = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
|
||||
// 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]);
|
||||
|
||||
if ($stmt->fetchColumn() == 0) {
|
||||
|
||||
@@ -6,9 +6,9 @@ use App\Core\Database;
|
||||
|
||||
class QuickPrintModel {
|
||||
|
||||
public function getAllBySession($sessionName) {
|
||||
public function getAllByRouterId($routerId) {
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -20,17 +20,22 @@ class QuickPrintModel {
|
||||
|
||||
public function add($data) {
|
||||
$db = Database::getInstance();
|
||||
$sql = "INSERT INTO quick_prints (session_name, name, server, profile, prefix, char_length, price, time_limit, data_limit, comment, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
// Insert router_id. session_name is kept for legacy/redundancy if needed, or we can drop it.
|
||||
// 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, [
|
||||
$data['session_name'],
|
||||
$data['router_id'],
|
||||
$data['session_name'], // Keep filling it for now
|
||||
$data['name'],
|
||||
$data['server'],
|
||||
$data['server'] ?? 'all',
|
||||
$data['profile'],
|
||||
$data['prefix'] ?? '',
|
||||
$data['char_length'] ?? 4,
|
||||
$data['price'] ?? 0,
|
||||
$data['selling_price'] ?? ($data['price'] ?? 0),
|
||||
$data['time_limit'] ?? '',
|
||||
$data['data_limit'] ?? '',
|
||||
$data['comment'] ?? '',
|
||||
@@ -40,15 +45,15 @@ class QuickPrintModel {
|
||||
|
||||
public function update($id, $data) {
|
||||
$db = Database::getInstance();
|
||||
$sql = "UPDATE quick_prints SET name=?, server=?, profile=?, prefix=?, char_length=?, price=?, time_limit=?, data_limit=?, comment=?, color=?, updated_at=CURRENT_TIMESTAMP WHERE id=?";
|
||||
$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, [
|
||||
$data['name'],
|
||||
$data['server'],
|
||||
$data['profile'],
|
||||
$data['prefix'] ?? '',
|
||||
$data['char_length'] ?? 4,
|
||||
$data['price'] ?? 0,
|
||||
$data['selling_price'] ?? ($data['price'] ?? 0),
|
||||
$data['time_limit'] ?? '',
|
||||
$data['data_limit'] ?? '',
|
||||
$data['comment'] ?? '',
|
||||
|
||||
@@ -12,10 +12,9 @@ class VoucherTemplateModel {
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function getBySession($sessionName) {
|
||||
// Templates can be global or session specific, but allow session filtering
|
||||
public function getAllByRouterId($routerId) {
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -27,8 +26,9 @@ class VoucherTemplateModel {
|
||||
|
||||
public function add($data) {
|
||||
$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, [
|
||||
$data['router_id'],
|
||||
$data['session_name'],
|
||||
$data['name'],
|
||||
$data['content']
|
||||
|
||||
@@ -322,9 +322,34 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
// Init
|
||||
fetchInterfaces().then(() => {
|
||||
// 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();
|
||||
});
|
||||
|
||||
// Localization Support
|
||||
const updateChartLabels = () => {
|
||||
if (window.i18n && window.i18n.isLoaded) {
|
||||
const rxLabel = window.i18n.t('dashboard.rx_download');
|
||||
const txLabel = window.i18n.t('dashboard.tx_upload');
|
||||
|
||||
// Only update if changed
|
||||
if (chart.data.datasets[0].label !== rxLabel || chart.data.datasets[1].label !== txLabel) {
|
||||
chart.data.datasets[0].label = rxLabel;
|
||||
chart.data.datasets[1].label = txLabel;
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for language changes
|
||||
if (window.Mivo) {
|
||||
window.Mivo.on('languageChanged', updateChartLabels);
|
||||
}
|
||||
window.addEventListener('languageChanged', updateChartLabels);
|
||||
|
||||
// Try initial update after a short delay to ensure i18n is ready if race condition
|
||||
setTimeout(updateChartLabels, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Design System</h1>
|
||||
<p class="text-accents-5">Component library and style guide for Mikhmon v3.</p>
|
||||
<p class="text-accents-5">Component library and style guide for Mivo.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="document.documentElement.classList.remove('dark')" class="btn bg-gray-200 text-gray-800">Light</button>
|
||||
|
||||
@@ -16,17 +16,21 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
|
||||
<h1 class="text-6xl font-extrabold tracking-tighter mb-4 text-foreground"><?= $errorCode ?></h1>
|
||||
<h2 class="text-2xl font-bold mb-4 text-foreground"><?= $errorMessage ?></h2>
|
||||
|
||||
<p class="text-accents-5 max-w-md mx-auto mb-8">
|
||||
<!-- 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 ?>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4 w-full sm:w-auto">
|
||||
<a href="/" class="btn btn-primary w-full sm:w-auto">
|
||||
<a href="/" class="btn btn-primary w-full sm:w-auto" data-i18n="errors.return_home">
|
||||
Return Home
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="w-full max-w-4xl mx-auto py-8 md:py-16 px-4 sm:px-6 text-center">
|
||||
<div class="mb-8 flex justify-center">
|
||||
<div class="h-16 w-16 bg-transparent rounded-full flex items-center justify-center">
|
||||
<img src="/assets/img/logo-m.svg" alt="Mikhmon Logo" class="h-16 w-auto block dark:hidden">
|
||||
<img src="/assets/img/logo-m-dark.svg" alt="Mikhmon Logo" class="h-16 w-auto hidden dark:block">
|
||||
<img src="/assets/img/logo-m.svg" alt="Mivo Logo" class="h-16 w-auto block dark:hidden">
|
||||
<img src="/assets/img/logo-m-dark.svg" alt="Mivo Logo" class="h-16 w-auto hidden dark:block">
|
||||
</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">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
||||
</a>
|
||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/add" class="btn btn-primary">
|
||||
<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>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,8 +79,21 @@ sort($uniqueModes);
|
||||
<tbody id="table-body">
|
||||
<?php if (!empty($profiles)): ?>
|
||||
<?php foreach ($profiles as $profile): ?>
|
||||
<tr class="table-row-item"
|
||||
data-name="<?= strtolower($profile['name'] ?? '') ?>"
|
||||
<tr class="table-row-item group-row"
|
||||
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'] ?? '') ?>">
|
||||
|
||||
<td>
|
||||
@@ -89,9 +102,9 @@ sort($uniqueModes);
|
||||
<i data-lucide="ticket" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400">
|
||||
<button onclick="openProfileModal('edit', this)" class="hover:underline hover:text-purple-600 dark:hover:text-purple-400 text-left">
|
||||
<?= htmlspecialchars($profile['name'] ?? '-') ?>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -129,9 +142,9 @@ sort($uniqueModes);
|
||||
|
||||
<td class="text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/profile/edit/<?= $profile['.id'] ?>" class="btn bg-blue-50 hover:bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 border-transparent h-8 px-2 rounded transition-colors" title="Edit">
|
||||
<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>
|
||||
</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">
|
||||
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||
<input type="hidden" name="id" value="<?= $profile['.id'] ?>">
|
||||
@@ -238,7 +251,7 @@ sort($uniqueModes);
|
||||
|
||||
update() {
|
||||
this.filteredRows = this.allRows.filter(row => {
|
||||
const name = row.dataset.name || '';
|
||||
const name = row.dataset.searchName || '';
|
||||
const mode = row.dataset.mode || '';
|
||||
|
||||
if (this.filters.search && !name.includes(this.filters.search)) return false;
|
||||
@@ -308,4 +321,200 @@ sort($uniqueModes);
|
||||
const rows = document.querySelectorAll('.table-row-item');
|
||||
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>
|
||||
|
||||
<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);
|
||||
|
||||
// $servers is passed from controller
|
||||
if (!isset($servers)) $servers = [];
|
||||
|
||||
sort($uniqueComments);
|
||||
?>
|
||||
|
||||
@@ -27,9 +31,9 @@ sort($uniqueComments);
|
||||
<a href="/<?= htmlspecialchars($session) ?>/dashboard" class="btn btn-secondary">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <span data-i18n="common.dashboard">Dashboard</span>
|
||||
</a>
|
||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/add" class="btn btn-primary">
|
||||
<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>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,13 +111,38 @@ sort($uniqueComments);
|
||||
<tbody id="table-body">
|
||||
<?php if (!empty($users)): ?>
|
||||
<?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"
|
||||
data-name="<?= strtolower($user['name'] ?? '') ?>"
|
||||
data-profile="<?= $user['profile'] ?? 'default' ?>"
|
||||
data-comment="<?= htmlspecialchars($user['comment'] ?? '') ?>">
|
||||
data-id="<?= htmlspecialchars($id) ?>"
|
||||
data-name="<?= strtolower($name) ?>"
|
||||
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">
|
||||
<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>
|
||||
<div class="flex items-center w-full">
|
||||
@@ -122,19 +151,19 @@ sort($uniqueComments);
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($user['name'] ?? '-') ?></div>
|
||||
<div class="text-sm font-medium text-foreground truncate"><?= htmlspecialchars($name) ?></div>
|
||||
<?php
|
||||
$status = \App\Helpers\HotspotHelper::getUserStatus($user);
|
||||
echo \App\Helpers\ViewHelper::badge($status);
|
||||
?>
|
||||
</div>
|
||||
<div class="text-xs text-accents-5"><?= htmlspecialchars($user['password'] ?? '******') ?></div>
|
||||
<div class="text-xs text-accents-5"><?= htmlspecialchars($password) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
<?= htmlspecialchars($user['profile'] ?? 'default') ?>
|
||||
<?= htmlspecialchars($profile) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -148,19 +177,19 @@ sort($uniqueComments);
|
||||
</div>
|
||||
</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 class="text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||
<button onclick="printUser('<?= htmlspecialchars($user['.id']) ?>')" class="btn-icon" title="Print">
|
||||
<button onclick="printUser('<?= htmlspecialchars($id) ?>')" class="btn-icon" title="Print">
|
||||
<i data-lucide="printer" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<a href="/<?= htmlspecialchars($session) ?>/hotspot/user/edit/<?= urlencode($user['.id']) ?>" class="btn-icon inline-flex items-center justify-center" title="Edit">
|
||||
<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>
|
||||
</a>
|
||||
<form action="/<?= htmlspecialchars($session) ?>/hotspot/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm('Delete User?', 'Are you sure you want to delete user <?= htmlspecialchars($user['name'] ?? '') ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||
</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($name) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="session" value="<?= htmlspecialchars($session) ?>">
|
||||
<input type="hidden" name="id" value="<?= $user['.id'] ?>">
|
||||
<input type="hidden" name="id" value="<?= $id ?>">
|
||||
<button type="submit" class="btn-icon-danger" title="Delete">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -188,6 +217,133 @@ sort($uniqueComments);
|
||||
</div>
|
||||
|
||||
<?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>
|
||||
class TableManager {
|
||||
constructor(rows, itemsPerPage = 10) {
|
||||
@@ -244,9 +400,7 @@ sort($uniqueComments);
|
||||
}
|
||||
});
|
||||
|
||||
// Custom Select Listener (Mutation Observer or custom event if we emitted one,
|
||||
// but for now relying on underlying SELECT change or custom-select class behavior)
|
||||
// Since CustomSelect updates the original Select, we listen to change on original select
|
||||
// Filters
|
||||
document.getElementById('filter-profile').addEventListener('change', (e) => {
|
||||
this.filters.profile = e.target.value;
|
||||
this.currentPage = 1;
|
||||
@@ -259,10 +413,7 @@ sort($uniqueComments);
|
||||
this.update();
|
||||
});
|
||||
|
||||
// Re-bind actions when external CustomSelect updates the select value
|
||||
// CustomSelect triggers 'change' event on original select, so standard listener works!
|
||||
|
||||
// Listen for language change to update pagination text
|
||||
// Listen for language change
|
||||
window.addEventListener('languageChanged', () => {
|
||||
this.render();
|
||||
});
|
||||
@@ -272,10 +423,10 @@ sort($uniqueComments);
|
||||
// Apply Filters
|
||||
this.filteredRows = this.allRows.filter(row => {
|
||||
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 || '';
|
||||
|
||||
// 1. Search (Name or Comment)
|
||||
// 1. Search
|
||||
if (this.filters.search) {
|
||||
const matchName = name.includes(this.filters.search);
|
||||
const matchComment = comment.includes(this.filters.search);
|
||||
@@ -285,7 +436,7 @@ sort($uniqueComments);
|
||||
// 2. Profile
|
||||
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;
|
||||
|
||||
return true;
|
||||
@@ -303,7 +454,7 @@ sort($uniqueComments);
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||
const end = Math.min(start + this.itemsPerPage, total);
|
||||
|
||||
// Update Text (Use Translation)
|
||||
// Update Text
|
||||
if (window.i18n) {
|
||||
const text = window.i18n.t('common.table.showing', {
|
||||
start: total === 0 ? 0 : start + 1,
|
||||
@@ -312,9 +463,9 @@ sort($uniqueComments);
|
||||
});
|
||||
document.getElementById('pagination-text').textContent = text;
|
||||
} else {
|
||||
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
|
||||
this.elements.endIdx.textContent = end;
|
||||
this.elements.totalCount.textContent = total;
|
||||
// Fallback
|
||||
const el = document.getElementById('pagination-text');
|
||||
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
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
// Re-init Icons for new rows
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
// Re-init Icons
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
// Update Checkbox Logic (Select All should act on visible?)
|
||||
// We usually reset "Select All" check when page changes
|
||||
// Reset "Select All"
|
||||
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', () => {
|
||||
// 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') {
|
||||
document.querySelectorAll('.custom-select').forEach(select => {
|
||||
new CustomSelect(select);
|
||||
});
|
||||
document.querySelectorAll('.custom-select.form-filter').forEach(s => new CustomSelect(s));
|
||||
}
|
||||
|
||||
// Init Table
|
||||
const rows = document.querySelectorAll('.table-row-item');
|
||||
const manager = new TableManager(rows, 10);
|
||||
|
||||
// --- Toolbar Logic (Copied/Adapted) ---
|
||||
const selectAll = document.getElementById('select-all');
|
||||
const toolbar = document.getElementById('batch-toolbar');
|
||||
const countSpan = document.getElementById('selected-count');
|
||||
const tableBody = document.getElementById('table-body'); // Dynamic body
|
||||
|
||||
// Toolbar Logic
|
||||
function updateToolbar() {
|
||||
const checked = document.querySelectorAll('.user-checkbox:checked');
|
||||
countSpan.textContent = checked.length;
|
||||
|
||||
if (checked.length > 0) {
|
||||
toolbar.classList.remove('translate-y-20', 'opacity-0');
|
||||
} else {
|
||||
toolbar.classList.add('translate-y-20', 'opacity-0');
|
||||
}
|
||||
if (checked.length > 0) toolbar.classList.remove('translate-y-20', 'opacity-0');
|
||||
else toolbar.classList.add('translate-y-20', 'opacity-0');
|
||||
}
|
||||
|
||||
if(selectAll) {
|
||||
selectAll.addEventListener('change', (e) => {
|
||||
const isChecked = e.target.checked;
|
||||
// Only select visible rows on current page
|
||||
// Only select visible rows
|
||||
const visibleCheckboxes = tableBody.querySelectorAll('.user-checkbox');
|
||||
visibleCheckboxes.forEach(cb => cb.checked = isChecked);
|
||||
updateToolbar();
|
||||
});
|
||||
}
|
||||
|
||||
// Event Delegation for dynamic rows
|
||||
if(tableBody) {
|
||||
tableBody.addEventListener('change', (e) => {
|
||||
if (e.target.classList.contains('user-checkbox')) {
|
||||
updateToolbar();
|
||||
if (!e.target.checked) selectAll.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Actions
|
||||
function printUser(id) {
|
||||
const width = 400;
|
||||
const height = 600;
|
||||
const width = 400; const height = 600;
|
||||
const left = (window.innerWidth - width) / 2;
|
||||
const top = (window.innerHeight - height) / 2;
|
||||
const session = '<?= htmlspecialchars($session) ?>';
|
||||
const url = `/${session}/hotspot/print/${encodeURIComponent(id)}`;
|
||||
window.open(url, `PrintUser`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
||||
window.open(`/${session}/hotspot/print/${encodeURIComponent(id)}`, `PrintUser`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
||||
}
|
||||
|
||||
function printSelected() {
|
||||
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
|
||||
if (selected.length === 0) return alert(window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "No users selected.");
|
||||
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 height = 600;
|
||||
const width = 800; const height = 600;
|
||||
const left = (window.innerWidth - width) / 2;
|
||||
const top = (window.innerHeight - height) / 2;
|
||||
const session = '<?= htmlspecialchars($session) ?>';
|
||||
const ids = selected.map(id => encodeURIComponent(id)).join(',');
|
||||
const url = `/${session}/hotspot/print-batch?ids=${ids}`;
|
||||
window.open(url, `PrintBatch`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
||||
window.open(`/${session}/hotspot/print-batch?ids=${ids}`, `PrintBatch`, `width=${width},height=${height},top=${top},left=${left},scrollbars=yes`);
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
const selected = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => cb.value);
|
||||
if (selected.length === 0) return alert(window.i18n ? window.i18n.t('hotspot_users.no_users_selected') : "Please select at least one user.");
|
||||
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 msg = window.i18n ? window.i18n.t('common.confirm_delete') : `Are you sure you want to delete ${selected.length} users?`;
|
||||
|
||||
Mivo.confirm(title, msg, window.i18n.t('common.delete'), window.i18n.t('common.cancel')).then(res => {
|
||||
if (!res) return;
|
||||
|
||||
// Create a form to submit
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/<?= htmlspecialchars($session) ?>/hotspot/delete'; // Re-uses the delete endpoint
|
||||
|
||||
const sessionInput = document.createElement('input');
|
||||
sessionInput.type = 'hidden';
|
||||
sessionInput.name = 'session';
|
||||
sessionInput.value = '<?= htmlspecialchars($session) ?>';
|
||||
form.appendChild(sessionInput);
|
||||
|
||||
form.action = '/<?= htmlspecialchars($session) ?>/hotspot/delete';
|
||||
const sInput = document.createElement('input');
|
||||
sInput.type = 'hidden'; sInput.name = 'session'; sInput.value = '<?= htmlspecialchars($session) ?>';
|
||||
form.appendChild(sInput);
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'hidden';
|
||||
idInput.name = 'id';
|
||||
idInput.value = selected.join(','); // Comma separated IDs
|
||||
idInput.type = 'hidden'; idInput.name = 'id'; idInput.value = selected.join(',');
|
||||
form.appendChild(idInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,26 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
<!-- Steps UI -->
|
||||
|
||||
@@ -6,13 +6,34 @@
|
||||
<?php else: ?>
|
||||
</div> <!-- /.container (Navbar Global) -->
|
||||
|
||||
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200">
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 text-center text-sm text-accents-5">
|
||||
<p><?= \App\Config\SiteConfig::getFooter() ?></p>
|
||||
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200 py-8 text-center space-y-4">
|
||||
<!-- Links Row -->
|
||||
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
|
||||
<a href="https://mivodev.github.io" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a href="https://github.com/mivodev/mivo/discussions" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||
<span>Community</span>
|
||||
</a>
|
||||
<a href="https://github.com/mivodev/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="github" class="w-4 h-4"></i>
|
||||
<span>Repo</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Copyright Row -->
|
||||
<div class="text-xs text-accents-4 opacity-50">
|
||||
<?= \App\Config\SiteConfig::getFooter() ?>
|
||||
</div>
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
window.MIVO_VERSION = "<?= \App\Config\SiteConfig::APP_VERSION ?>";
|
||||
</script>
|
||||
<script src="/assets/js/modules/update-checker.js"></script>
|
||||
<script>
|
||||
// Global Theme Toggle Logic (Class-based for multiple instances)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -129,20 +150,67 @@
|
||||
</script>
|
||||
<script>
|
||||
// Global Dropdown & Sidebar Logic
|
||||
let menuTimeout;
|
||||
|
||||
function toggleMenu(menuId, button) {
|
||||
if (menuTimeout) clearTimeout(menuTimeout);
|
||||
|
||||
const menu = document.getElementById(menuId);
|
||||
if (!menu) return;
|
||||
|
||||
// Handle Dropdowns (IDs start with 'lang-' or 'session-')
|
||||
if (menuId.startsWith('lang-') || menuId === 'session-dropdown') {
|
||||
if (menu.classList.contains('invisible')) {
|
||||
// Handle Dropdowns (IDs start with 'lang-', 'session-', or is 'notification-')
|
||||
if (menuId.startsWith('lang-') || menuId === 'session-dropdown' || menuId === 'notification-dropdown') {
|
||||
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
|
||||
menu.classList.remove('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||
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 {
|
||||
// Close
|
||||
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 (menuId === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||
sidebarHeader.classList.add('overflow-hidden');
|
||||
sidebarHeader.classList.remove('overflow-visible');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -175,20 +243,22 @@
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
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 => {
|
||||
if (!dropdown.classList.contains('invisible')) {
|
||||
// Find the trigger button (previous sibling usually)
|
||||
// Robust way: check if click is inside dropdown OR inside the button that toggles it
|
||||
// Since button calls toggleMenu, we just need to ignore clicks inside dropdown and button?
|
||||
// Actually, simpler: just check if click is OUTSIDE dropdown.
|
||||
// But if click is on button, let button handler toggle it (don't double toggle).
|
||||
const sidebarHeader = document.getElementById('sidebar-header');
|
||||
|
||||
if (!dropdown.classList.contains('invisible')) {
|
||||
const button = document.querySelector(`button[onclick*="'${dropdown.id}'"]`);
|
||||
|
||||
if (!dropdown.contains(event.target) && (!button || !button.contains(event.target))) {
|
||||
dropdown.classList.add('opacity-0', 'scale-95', 'invisible', 'pointer-events-none');
|
||||
dropdown.classList.remove('opacity-100', 'scale-100', 'visible', 'pointer-events-auto');
|
||||
|
||||
// Revert Sidebar Overflow if needed
|
||||
if (dropdown.id === 'lang-dropdown-sidebar' && sidebarHeader) {
|
||||
sidebarHeader.classList.add('overflow-hidden');
|
||||
sidebarHeader.classList.remove('overflow-visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -209,18 +279,37 @@
|
||||
if (data.success) {
|
||||
Mivo.toast('success', title.replace('?', ''), 'The command has been sent to the router.');
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Action Failed',
|
||||
text: data.error || 'Unknown error occurred.',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
backdrop: 'rgba(0,0,0,0.1)'
|
||||
});
|
||||
Mivo.alert('error', 'Action Failed', data.error || 'Unknown error occurred.');
|
||||
}
|
||||
} catch (err) {
|
||||
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>
|
||||
<?php \App\Core\Hooks::doAction('mivo_footer'); ?>
|
||||
</body>
|
||||
</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/discussions" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||
<span>Community</span>
|
||||
</a>
|
||||
<a href="https://github.com/mivodev/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="github" class="w-4 h-4"></i>
|
||||
<span>Repo</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Copyright Row -->
|
||||
<div class="text-xs text-accents-4 opacity-50">
|
||||
<?= \App\Config\SiteConfig::getFooter() ?>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
@@ -38,34 +56,17 @@
|
||||
|
||||
// Use Custom Toasts for most notifications (Success, Info, Error)
|
||||
// Only use Modal (Swal) for specific heavy warnings or questions if needed
|
||||
// Use Toasts for standard notifications
|
||||
if (['success', 'info', 'error', 'warning'].includes(type)) {
|
||||
// Assuming Mivo.toast is available globally or via another script check
|
||||
if (window.Mivo && window.Mivo.toast) {
|
||||
Mivo.toast(type, title, message);
|
||||
} else {
|
||||
console.log('Toast:', title, message);
|
||||
}
|
||||
} else {
|
||||
// Use Swal for 'question' or fallback
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`,
|
||||
title: title,
|
||||
text: message,
|
||||
confirmButtonText: 'OK',
|
||||
customClass: {
|
||||
popup: 'swal2-premium-card',
|
||||
confirmButton: 'btn btn-primary',
|
||||
cancelButton: 'btn btn-secondary',
|
||||
},
|
||||
buttonsStyling: false,
|
||||
heightAuto: false,
|
||||
didOpen: () => {
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert(`${title}\n${message}`);
|
||||
// For questions or other types, use Modal Alert
|
||||
if (window.Mivo && window.Mivo.alert) {
|
||||
Mivo.alert(type || 'info', title, message);
|
||||
} else if (typeof Swal !== 'undefined') {
|
||||
Swal.fire(title, message, type);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -78,5 +79,6 @@
|
||||
});
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
<?php \App\Core\Hooks::doAction('mivo_footer'); ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,8 +19,8 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
|
||||
<!-- Tailwind CSS (Local) -->
|
||||
<link rel="stylesheet" href="/assets/css/styles.css">
|
||||
|
||||
<!-- Flag Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" />
|
||||
<!-- Flag Icons (Local) -->
|
||||
<link rel="stylesheet" href="/assets/vendor/flag-icons/css/flag-icons.min.css" />
|
||||
|
||||
|
||||
<style>
|
||||
@@ -54,12 +54,16 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
|
||||
</script>
|
||||
<script src="/assets/js/jquery.min.js"></script>
|
||||
<script src="/assets/js/lucide.min.js"></script>
|
||||
<script src="/assets/js/custom-select.js" defer></script>
|
||||
<script src="/assets/js/datatable.js" defer></script>
|
||||
<script>
|
||||
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/alert-helper.js" defer></script>
|
||||
<script src="/assets/js/i18n.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>
|
||||
/* Global Form Input Style - Matches Vercel Design System */
|
||||
@@ -110,6 +114,8 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<?php \App\Core\Hooks::doAction('mivo_head'); ?>
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen bg-background text-foreground anti-aliased relative">
|
||||
<!-- Background Elements (Global Sci-Fi Grid) -->
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/assets/css/styles.css">
|
||||
<script src="/assets/js/lucide.min.js"></script>
|
||||
<script src="/assets/js/sweetalert2.all.min.js" defer></script>
|
||||
<script src="/assets/js/alert-helper.js" defer></script>
|
||||
<script src="/assets/js/i18n.js" defer></script>
|
||||
<script src="/assets/js/mivo.js" defer></script>
|
||||
<script src="/assets/js/modules/alert.js" defer></script>
|
||||
<script src="/assets/js/modules/i18n.js" defer></script>
|
||||
<style>
|
||||
/* Custom Keyframes */
|
||||
@keyframes fade-in-up {
|
||||
@@ -28,6 +29,7 @@
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
<?php \App\Core\Hooks::doAction('mivo_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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- Floating Theme Toggle (Bottom Right) -->
|
||||
<button id="theme-toggle" class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-background border border-accents-2 shadow-lg text-accents-5 hover:text-foreground hover:border-foreground transition-all duration-300 group" style="position: fixed; bottom: 1.5rem; right: 1.5rem;">
|
||||
<i data-lucide="moon" class="w-5 h-5 block dark:hidden group-hover:scale-110 transition-transform"></i>
|
||||
<i data-lucide="sun" class="w-5 h-5 hidden dark:block group-hover:scale-110 transition-transform"></i>
|
||||
<!-- Top Right Controls (Pill Theme Toggle & Lang Switcher) -->
|
||||
<div class="fixed top-4 right-4 z-50 flex items-center space-x-3">
|
||||
<!-- Language Switcher -->
|
||||
<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>
|
||||
<!-- 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>
|
||||
// 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', () => {
|
||||
lucide.createIcons();
|
||||
|
||||
// Theme Toggle Logic
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
// Theme Logic
|
||||
const glider = document.getElementById('theme-glider');
|
||||
const btnLight = document.getElementById('btn-light');
|
||||
const btnDark = document.getElementById('btn-dark');
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
if(themeToggleBtn){
|
||||
themeToggleBtn.addEventListener('click', () => {
|
||||
if (htmlElement.classList.contains('dark')) {
|
||||
htmlElement.classList.remove('dark');
|
||||
localStorage.theme = 'light';
|
||||
} else {
|
||||
window.setTheme = (theme) => {
|
||||
if (theme === 'dark') {
|
||||
htmlElement.classList.add('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>
|
||||
|
||||
@@ -15,28 +15,47 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
</a>
|
||||
|
||||
<!-- 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">
|
||||
<a href="/" class="relative py-1 <?= ($uri == '/' || $uri == '/home') ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Home</a>
|
||||
<a href="/settings" class="relative py-1 <?= (strpos($uri, '/settings') === 0) ? 'text-foreground after:absolute after:bottom-0 after:left-0 after:w-full after:h-0.5 after:bg-foreground' : 'text-accents-5 hover:text-foreground transition-colors' ?>">Settings</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Right side controls -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Desktop Control Pill (Hidden on Mobile) -->
|
||||
<div class="hidden md:flex control-pill scale-95 hover:scale-100 transition-transform">
|
||||
<!-- Language Switcher -->
|
||||
<div class="relative group">
|
||||
<button type="button" class="pill-lang-btn" onclick="toggleMenu('lang-dropdown-nav', this)" title="Change Language">
|
||||
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
|
||||
<!-- Notification Bell -->
|
||||
<div class="relative group" onmouseleave="closeMenu('notification-dropdown')">
|
||||
<button id="notification-bell" type="button" class="pill-lang-btn relative" onclick="toggleMenu('notification-dropdown', this)" title="Notifications">
|
||||
<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>
|
||||
<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>
|
||||
<?php
|
||||
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
||||
foreach ($languages as $lang):
|
||||
$pathArg = isset($lang['path']) ? "', '" . $lang['path'] : "";
|
||||
?>
|
||||
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang">
|
||||
<button onclick="Mivo.modules.I18n.loadLanguage('<?= $lang['code'] ?><?= $pathArg ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang">
|
||||
<span class="fi fi-<?= $lang['flag'] ?> rounded-sm shadow-sm transition-transform group-hover/lang:scale-110"></span>
|
||||
<span><?= $lang['name'] ?></span>
|
||||
</button>
|
||||
@@ -44,8 +63,6 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pill-divider"></div>
|
||||
|
||||
<!-- Theme Toggle (Segmented) -->
|
||||
<div class="segmented-switch theme-toggle" title="Toggle Theme">
|
||||
<div class="segmented-switch-slider"></div>
|
||||
@@ -88,6 +105,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
<div id="mobile-navbar-menu" class="md:hidden border-t border-accents-2 bg-background/95 backdrop-blur-xl transition-all duration-300 ease-in-out max-h-0 opacity-0 invisible overflow-hidden">
|
||||
<div class="px-4 pt-4 pb-6 space-y-4">
|
||||
<!-- Nav Links -->
|
||||
<?php if(isset($_SESSION['user_id'])): ?>
|
||||
<div class="flex flex-col gap-1">
|
||||
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-xl <?= ($uri == '/' || $uri == '/home') ? 'bg-foreground/5 text-foreground font-bold' : 'text-accents-5 hover:bg-accents-1' ?>">
|
||||
<i data-lucide="home" class="w-5 h-5 !text-black dark:!text-white" stroke-width="2.5"></i>
|
||||
@@ -98,6 +116,7 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Mobile Controls Overlay -->
|
||||
<div class="p-4 rounded-2xl bg-accents-1/50 border border-accents-2 space-y-4">
|
||||
@@ -105,8 +124,10 @@ $uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
<span class="text-xs font-bold text-accents-4 uppercase tracking-wider">Select Language</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide snap-x">
|
||||
<?php foreach ($languages as $lang): ?>
|
||||
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="flex-shrink-0 flex items-center gap-2 px-4 py-2 rounded-full border border-accents-2 bg-background hover:border-foreground transition-all text-sm font-medium snap-start shadow-sm">
|
||||
<?php foreach ($languages as $lang):
|
||||
$pathArg = isset($lang['path']) ? "', '" . $lang['path'] : "";
|
||||
?>
|
||||
<button onclick="changeLanguage('<?= $lang['code'] ?><?= $pathArg ?>')" class="flex-shrink-0 flex items-center gap-2 px-4 py-2 rounded-full border border-accents-2 bg-background hover:border-foreground transition-all text-sm font-medium snap-start shadow-sm">
|
||||
<span class="fi fi-<?= $lang['flag'] ?> rounded-full shadow-sm"></span>
|
||||
<span class="whitespace-nowrap"><?= $lang['name'] ?></span>
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$isDashboard = strpos($uri, '/dashboard') !== 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;
|
||||
|
||||
// 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">
|
||||
<!-- 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">
|
||||
<!-- 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">
|
||||
@@ -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="control-pill scale-90 transition-transform hover:scale-100 shadow-lg bg-white/10 dark:bg-black/20 backdrop-blur-md">
|
||||
<!-- Language Switcher -->
|
||||
<div class="relative group/lang">
|
||||
<!-- 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">
|
||||
<i data-lucide="languages" class="w-4 h-4 !text-black dark:!text-white" stroke-width="2.5"></i>
|
||||
</button>
|
||||
<div id="lang-dropdown-sidebar" class="absolute left-1/2 -translate-x-1/2 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none z-50">
|
||||
<div 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>
|
||||
<?php
|
||||
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
||||
foreach ($languages as $lang):
|
||||
?>
|
||||
<button onclick="changeLanguage('<?= $lang['code'] ?>')" class="w-full text-left flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accents-1 transition-colors text-accents-6 hover:text-foreground group/lang-item">
|
||||
<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><?= $lang['name'] ?></span>
|
||||
</button>
|
||||
@@ -163,7 +165,7 @@ $getInitials = function($name) {
|
||||
<div class="flex-1 overflow-y-auto" style="direction: rtl;">
|
||||
<div class="py-4 px-3 space-y-1" style="direction: ltr;">
|
||||
<!-- Session Switcher -->
|
||||
<div class="px-3 mb-6 relative">
|
||||
<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)">
|
||||
<!-- 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">
|
||||
@@ -185,7 +187,7 @@ $getInitials = function($name) {
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div id="session-dropdown" class="absolute top-full left-3 w-[calc(100%-1.5rem)] z-50 mt-1 bg-background border border-accents-2 rounded-lg shadow-lg overflow-hidden transition-all duration-200 ease-out origin-top opacity-0 scale-95 invisible pointer-events-none">
|
||||
<div 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="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
|
||||
@@ -377,11 +379,37 @@ $getInitials = function($name) {
|
||||
</a>
|
||||
|
||||
<!-- Voucher Templates -->
|
||||
<a href="/settings/templates" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors <?= $isTemplates ? 'bg-white/40 dark:bg-white/5 shadow-sm text-foreground ring-1 ring-white/10' : 'text-accents-6 hover:text-foreground hover:bg-white/5' ?>">
|
||||
<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>
|
||||
<span data-i18n="sidebar.templates">Templates</span>
|
||||
</a>
|
||||
|
||||
<!-- Support Separator -->
|
||||
<div class="pt-4 pb-1 px-3">
|
||||
<div class="text-xs font-semibold text-accents-5 uppercase tracking-wider" data-i18n="sidebar.support">Support</div>
|
||||
</div>
|
||||
|
||||
<!-- Docs -->
|
||||
<a href="https://mivodev.github.io" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||
<span data-i18n="sidebar.docs">Documentation</span>
|
||||
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||
</a>
|
||||
|
||||
<!-- Community -->
|
||||
<a href="https://github.com/mivodev/mivo/discussions" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||
<span data-i18n="sidebar.community">Community</span>
|
||||
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||
</a>
|
||||
|
||||
<!-- Repo -->
|
||||
<a href="https://github.com/mivodev/mivo" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<i data-lucide="github" class="w-4 h-4"></i>
|
||||
<span data-i18n="sidebar.repo">Repository</span>
|
||||
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<i data-lucide="languages" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div id="lang-dropdown-mobile" class="absolute right-0 top-full mt-3 w-48 bg-background/90 backdrop-blur-xl border border-accents-2 rounded-xl shadow-xl overflow-hidden transition-all duration-200 ease-out origin-top-right opacity-0 scale-95 invisible pointer-events-none z-50">
|
||||
<div 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>
|
||||
<?php
|
||||
$languages = \App\Helpers\LanguageHelper::getAvailableLanguages();
|
||||
|
||||
@@ -11,9 +11,10 @@ function isActive($path, $current) {
|
||||
$menu = [
|
||||
['label' => 'routers_title', 'url' => '/settings', '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' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
|
||||
['label' => 'plugins_title', 'url' => '/settings/plugins', 'namespace' => 'settings'],
|
||||
];
|
||||
?>
|
||||
<nav id="settings-sidebar" class="w-full sticky top-[64px] z-40 bg-background/95 backdrop-blur border-b border-accents-2 transition-all duration-300">
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
|
||||
<i data-lucide="ticket" class="h-4 w-4 text-accents-5"></i>
|
||||
</div>
|
||||
<input type="text" id="voucher-code" class="form-input pl-10 h-11 text-lg font-mono tracking-wide" placeholder="Ex: QWASZX" required autofocus autocomplete="off">
|
||||
<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>
|
||||
|
||||
@@ -55,13 +55,6 @@
|
||||
|
||||
<!-- Logic Script -->
|
||||
<script>
|
||||
// Initialize Input placeholder
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const inp = document.getElementById('voucher-code');
|
||||
if(inp && window.i18n) {
|
||||
inp.placeholder = window.i18n.t('status.code_placeholder');
|
||||
}
|
||||
});
|
||||
|
||||
async function checkStatus(e) {
|
||||
e.preventDefault();
|
||||
@@ -110,7 +103,7 @@
|
||||
<!-- Header -->
|
||||
<div class="relative p-5 md:p-6 border-b border-white/10 flex justify-between items-center bg-white/10 dark:bg-black/20">
|
||||
<div>
|
||||
<span class="text-[10px] text-accents-5 font-bold uppercase tracking-widest block mb-0.5">Voucher Code</span>
|
||||
<span class="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>
|
||||
</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">
|
||||
@@ -121,7 +114,7 @@
|
||||
<!-- Data Usage Bar -->
|
||||
<div class="relative p-5 md:p-6 pb-2">
|
||||
<div class="flex justify-between items-end mb-2">
|
||||
<span class="text-xs font-bold text-accents-5 uppercase tracking-wide">Data Remaining</span>
|
||||
<span class="text-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>
|
||||
</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">
|
||||
@@ -130,7 +123,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right mt-1.5">
|
||||
<span class="text-[10px] font-semibold text-accents-4 uppercase tracking-wider">Used: <span class="text-foreground">${d.data_used}</span></span>
|
||||
<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>
|
||||
|
||||
@@ -139,19 +132,19 @@
|
||||
<table class="w-full text-sm text-left">
|
||||
<tbody class="divide-y divide-white/10">
|
||||
<tr>
|
||||
<td class="py-3 text-accents-5 font-bold uppercase tracking-wide text-[10px]">Package</td>
|
||||
<td class="py-3 text-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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -160,44 +153,32 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
Swal.fire({
|
||||
title: 'Voucher Details',
|
||||
html: htmlContent,
|
||||
icon: 'success', // Using success icon for positive result
|
||||
confirmButtonText: 'OK',
|
||||
customClass: {
|
||||
popup: 'swal2-premium-card w-full max-w-md', // Ensure good width
|
||||
confirmButton: 'btn btn-primary w-full',
|
||||
},
|
||||
buttonsStyling: false
|
||||
Mivo.alert('success', window.i18n.t('status.details_title'), htmlContent, {
|
||||
customClass: { popup: 'w-full max-w-md' } // Override width only, others merged
|
||||
});
|
||||
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Voucher Not Found',
|
||||
text: json.message || "The voucher code you entered does not exist.",
|
||||
confirmButtonText: 'Try Again',
|
||||
customClass: {
|
||||
popup: 'swal2-premium-card',
|
||||
confirmButton: 'btn btn-primary',
|
||||
},
|
||||
buttonsStyling: false,
|
||||
Mivo.alert('error',
|
||||
window.i18n.t('status.not_found_title'),
|
||||
json.message && json.message !== 'Voucher Not Found' ? json.message : window.i18n.t('status.not_found_desc'),
|
||||
{
|
||||
confirmButtonText: window.i18n.t('status.try_again'),
|
||||
didClose: () => {
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById('voucher-code');
|
||||
if(el) { el.focus(); el.select(); }
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'System Error',
|
||||
text: 'Unable to connect to the server.',
|
||||
title: window.i18n.t('errors.500_title'),
|
||||
text: window.i18n.t('errors.500_desc'),
|
||||
confirmButtonText: 'Close',
|
||||
customClass: {
|
||||
popup: 'swal2-premium-card',
|
||||
|
||||
@@ -54,8 +54,18 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<?php else: ?>
|
||||
<?php foreach ($packages as $pkg): ?>
|
||||
<tr class="table-row-item group"
|
||||
data-name="<?= strtolower($pkg['name']) ?>"
|
||||
data-price="<?= $pkg['price'] ?>">
|
||||
data-id="<?= htmlspecialchars($pkg['id']) ?>"
|
||||
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">
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
</button>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,29 +111,23 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div id="modal-overlay" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center opacity-0 transition-opacity duration-200">
|
||||
<div id="modal-content" class="card w-full max-w-lg mx-4 transform scale-95 transition-transform duration-200 overflow-hidden p-0">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-accents-2 bg-accents-1/30">
|
||||
<h3 class="text-lg font-bold text-foreground" id="modal-title" data-i18n="quick_print.add_package">Add Package</h3>
|
||||
<button onclick="closeModal()" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="p-6 space-y-4">
|
||||
<!-- Template for Add/Edit Package Form -->
|
||||
<template id="package-form-template">
|
||||
<form id="qp-form" action="/<?= htmlspecialchars($session) ?>/quick-print/store" method="POST" class="space-y-4 text-left">
|
||||
<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 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<label class="form-label" data-i18n="quick_print.package_name">Package Name</label>
|
||||
<input type="text" name="name" required class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary placeholder:text-accents-3" placeholder="e.g. 3 Hours Voucher">
|
||||
<input type="text" name="name" required class="w-full" placeholder="e.g. 3 Hours Voucher">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label" data-i18n="quick_print.select_profile">Select Profile</label>
|
||||
<select name="profile" class="custom-select w-full" data-search="true">
|
||||
<select name="profile" class="w-full" data-search="true">
|
||||
<?php foreach($profiles as $p): ?>
|
||||
<option value="<?= htmlspecialchars($p['name']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
@@ -132,7 +136,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
|
||||
<div>
|
||||
<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-red-500" data-i18n="colors.red">Red</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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="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>
|
||||
@@ -170,27 +174,21 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
|
||||
<div>
|
||||
<label class="form-label" data-i18n="quick_print.time_limit">Time Limit</label>
|
||||
<input type="text" name="time_limit" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="3h">
|
||||
<input type="text" name="time_limit" class="w-full" placeholder="3h">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label" data-i18n="quick_print.data_limit">Data Limit</label>
|
||||
<input type="text" name="data_limit" class="w-full 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 class="col-span-1 md:col-span-2">
|
||||
<label class="form-label" data-i18n="system_tools.comment">Comment</label>
|
||||
<input type="text" name="comment" class="w-full bg-background border border-accents-2 rounded-md px-3 py-2 text-foreground focus:ring-1 focus:ring-primary focus:border-primary" placeholder="Description or Note">
|
||||
<input type="text" name="comment" class="w-full" placeholder="Description or Note">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-accents-2 mt-4">
|
||||
<button type="button" onclick="closeModal()" class="btn btn-secondary" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-i18n="quick_print.save_package">Save Package</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
class TableManager {
|
||||
@@ -298,27 +296,63 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = document.getElementById('modal-overlay');
|
||||
const content = document.getElementById('modal-content');
|
||||
function openModal(mode, btn = null) {
|
||||
const template = document.getElementById('package-form-template').innerHTML;
|
||||
|
||||
function openModal(mode) {
|
||||
overlay.classList.remove('hidden');
|
||||
// Trigger reflow
|
||||
void overlay.offsetWidth;
|
||||
let title = window.i18n ? window.i18n.t('quick_print.add_package') : 'Add Package';
|
||||
let saveBtn = window.i18n ? window.i18n.t('quick_print.save_package') : 'Save Package';
|
||||
|
||||
overlay.classList.remove('opacity-0');
|
||||
content.classList.add('open');
|
||||
// Validation Callback
|
||||
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() {
|
||||
overlay.classList.add('opacity-0');
|
||||
content.classList.remove('open');
|
||||
|
||||
setTimeout(() => {
|
||||
overlay.classList.add('hidden');
|
||||
}, 300);
|
||||
// Pass callbacks to helper
|
||||
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -26,8 +26,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
|
||||
<!-- Daily Tab -->
|
||||
<div id="content-daily" class="tab-content">
|
||||
<div class="table-container">
|
||||
<table class="table-glass">
|
||||
<table class="table-glass" id="table-daily">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="reports.date">Date</th>
|
||||
@@ -38,18 +37,16 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<?php foreach ($daily as $date => $total): ?>
|
||||
<tr>
|
||||
<td><?= $date ?></td>
|
||||
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Tab -->
|
||||
<div id="content-monthly" class="tab-content hidden">
|
||||
<div class="table-container">
|
||||
<table class="table-glass">
|
||||
<table class="table-glass" id="table-monthly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="reports.month">Month</th>
|
||||
@@ -60,18 +57,16 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<?php foreach ($monthly as $date => $total): ?>
|
||||
<tr>
|
||||
<td><?= $date ?></td>
|
||||
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Yearly Tab -->
|
||||
<div id="content-yearly" class="tab-content hidden">
|
||||
<div class="table-container">
|
||||
<table class="table-glass">
|
||||
<table class="table-glass" id="table-yearly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="reports.year">Year</th>
|
||||
@@ -82,16 +77,25 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<?php foreach ($yearly as $date => $total): ?>
|
||||
<tr>
|
||||
<td><?= $date ?></td>
|
||||
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/components/datatable.js"></script>
|
||||
<script>
|
||||
function switchTab(tabName) {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Init Datatables
|
||||
if (typeof SimpleDataTable !== 'undefined') {
|
||||
new SimpleDataTable('#table-daily', { itemsPerPage: 10, searchable: true });
|
||||
new SimpleDataTable('#table-monthly', { itemsPerPage: 10, searchable: true });
|
||||
new SimpleDataTable('#table-yearly', { itemsPerPage: 10, searchable: true });
|
||||
}
|
||||
});
|
||||
|
||||
function switchTab(tabName) {
|
||||
// Hide all contents
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
// Show selected
|
||||
@@ -107,7 +111,7 @@ function switchTab(tabName) {
|
||||
const btn = document.getElementById('tab-' + tabName);
|
||||
btn.classList.remove('border-transparent', 'text-accents-5');
|
||||
btn.classList.add('border-primary', 'text-primary');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
|
||||
@@ -9,6 +9,20 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<p class="text-accents-5"><span data-i18n="reports.selling_subtitle">Sales summary and details for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="dropdown dropdown-end relative" id="export-dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" onclick="document.getElementById('export-menu').classList.toggle('hidden')">
|
||||
<i data-lucide="download" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.export">Export</span>
|
||||
<i data-lucide="chevron-down" class="w-4 h-4 ml-1"></i>
|
||||
</button>
|
||||
<div id="export-menu" class="dropdown-menu hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-black border border-accents-2 z-50 p-1">
|
||||
<button onclick="exportReport('csv')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
|
||||
<i data-lucide="file-text" class="w-4 h-4 mr-2 text-green-600"></i> Export CSV
|
||||
</button>
|
||||
<button onclick="exportReport('xlsx')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
|
||||
<i data-lucide="sheet" class="w-4 h-4 mr-2 text-green-600"></i> Export Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="location.reload()" class="btn btn-secondary">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
|
||||
</button>
|
||||
@@ -16,58 +30,75 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<i data-lucide="printer" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.print_report">Print Report</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card bg-accents-1 border-accents-2">
|
||||
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_income">Total Income</div>
|
||||
<div class="text-3xl font-bold text-green-500 mt-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Stock / Potential -->
|
||||
<div class="card">
|
||||
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.generated_stock">Generated Stock</div>
|
||||
<div class="text-3xl font-bold text-accents-6 mt-2">
|
||||
<?= \App\Helpers\FormatHelper::formatCurrency($totalIncome, $currency) ?>
|
||||
</div>
|
||||
<div class="text-xs text-accents-5 mt-1">
|
||||
<?= number_format($totalVouchers) ?> vouchers
|
||||
</div>
|
||||
<div class="card bg-accents-1 border-accents-2">
|
||||
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_vouchers">Total Vouchers Sold</div>
|
||||
<div class="text-3xl font-bold text-blue-500 mt-2">
|
||||
<?= number_format($totalVouchers, 0, ',', '.') ?>
|
||||
</div>
|
||||
|
||||
<!-- Realized / Actual -->
|
||||
<div class="card !bg-green-500/10 !border-green-500/20">
|
||||
<div class="text-sm text-green-600 dark:text-green-400 uppercase font-bold tracking-wide" data-i18n="reports.realized_income">Realized Income</div>
|
||||
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">
|
||||
<?= \App\Helpers\FormatHelper::formatCurrency($totalRealizedIncome ?? 0, $currency) ?>
|
||||
</div>
|
||||
<div class="text-xs text-green-600/70 dark:text-green-400/70 mt-1">
|
||||
<?= number_format($totalUsedVouchers ?? 0) ?> used
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Filter Bar -->
|
||||
<div class="flex flex-col md:flex-row gap-4 justify-between items-center no-print">
|
||||
<!-- Search -->
|
||||
<div class="relative w-full md:w-64">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
|
||||
</div>
|
||||
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search date..." data-i18n-placeholder="common.table.search_placeholder">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Table -->
|
||||
<div class="table-container">
|
||||
<table class="table-glass" id="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="date" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
|
||||
<th class="text-right" data-i18n="reports.qty">Qty</th>
|
||||
<th data-sort="total" class="sortable text-right cursor-pointer hover:text-foreground select-none" data-i18n="reports.total">Total</th>
|
||||
<th data-sort="date" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
|
||||
<th data-i18n="reports.status">Status</th>
|
||||
<th class="text-right" data-i18n="reports.qty">Qty (Stock)</th>
|
||||
<th class="text-right text-green-500" data-i18n="reports.used">Used</th>
|
||||
<th data-sort="total" class="text-right" data-i18n="reports.total_stock">Total Stock</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<?php if (empty($report)): ?>
|
||||
<tr>
|
||||
<td colspan="3" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
|
||||
<td colspan="5" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($report as $row): ?>
|
||||
<tr class="table-row-item"
|
||||
data-date="<?= strtolower($row['date']) ?>"
|
||||
data-total="<?= $row['total'] ?>">
|
||||
<td class="font-medium"><?= htmlspecialchars($row['date']) ?></td>
|
||||
<td class="text-right font-mono"><?= number_format($row['count']) ?></td>
|
||||
<tr class="table-row-item">
|
||||
<td class="font-medium">
|
||||
<?= htmlspecialchars($row['date']) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if($row['status'] === 'New'): ?>
|
||||
<span class="px-2 py-1 text-xs font-bold rounded-md bg-accents-2 text-accents-6">NEW</span>
|
||||
<?php elseif($row['status'] === 'Selling'): ?>
|
||||
<span class="px-2 py-1 text-xs font-bold rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/20">SELLING</span>
|
||||
<?php elseif($row['status'] === 'Sold Out'): ?>
|
||||
<span class="px-2 py-1 text-xs font-bold rounded-md bg-green-500/10 text-green-500 border border-green-500/20">SOLD OUT</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-right font-mono text-accents-6">
|
||||
<?= number_format($row['count']) ?>
|
||||
</td>
|
||||
<td class="text-right font-mono text-green-500 font-medium">
|
||||
<?= number_format($row['realized_count']) ?>
|
||||
<span class="text-xs opacity-70 block">
|
||||
<?= \App\Helpers\FormatHelper::formatCurrency($row['realized_total'], $currency) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right font-mono font-bold text-foreground">
|
||||
<?= \App\Helpers\FormatHelper::formatCurrency($row['total'], $currency) ?>
|
||||
</td>
|
||||
@@ -76,130 +107,84 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between no-print" id="pagination-controls">
|
||||
<div class="text-sm text-accents-5">
|
||||
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> rows
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
|
||||
<div id="page-numbers" class="flex gap-1"></div>
|
||||
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script src="/assets/js/components/datatable.js"></script>
|
||||
<!-- Local SheetJS Library -->
|
||||
<script src="/assets/vendor/xlsx/xlsx.full.min.js"></script>
|
||||
|
||||
<script>
|
||||
class TableManager {
|
||||
constructor(rows, itemsPerPage = 15) {
|
||||
this.allRows = Array.from(rows);
|
||||
this.filteredRows = this.allRows;
|
||||
this.itemsPerPage = itemsPerPage;
|
||||
this.currentPage = 1;
|
||||
|
||||
this.elements = {
|
||||
body: document.getElementById('table-body'),
|
||||
startIdx: document.getElementById('start-idx'),
|
||||
endIdx: document.getElementById('end-idx'),
|
||||
totalCount: document.getElementById('total-count'),
|
||||
prevBtn: document.getElementById('prev-btn'),
|
||||
nextBtn: document.getElementById('next-btn'),
|
||||
pageNumbers: document.getElementById('page-numbers')
|
||||
};
|
||||
|
||||
this.filters = { search: '' };
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Translate placeholder
|
||||
const searchInput = document.getElementById('global-search');
|
||||
if (searchInput && window.i18n) {
|
||||
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
|
||||
}
|
||||
document.getElementById('global-search').addEventListener('input', (e) => {
|
||||
this.filters.search = e.target.value.toLowerCase();
|
||||
this.currentPage = 1;
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
|
||||
this.elements.nextBtn.addEventListener('click', () => {
|
||||
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
|
||||
if(this.currentPage < max) { this.currentPage++; this.render(); }
|
||||
});
|
||||
|
||||
this.update();
|
||||
|
||||
// Listen for language change
|
||||
window.addEventListener('languageChanged', () => {
|
||||
const searchInput = document.getElementById('global-search');
|
||||
if (searchInput && window.i18n) {
|
||||
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
|
||||
}
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
this.filteredRows = this.allRows.filter(row => {
|
||||
const date = row.dataset.date || '';
|
||||
|
||||
if (this.filters.search && !date.includes(this.filters.search)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const total = this.filteredRows.length;
|
||||
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
|
||||
if (this.currentPage > maxPage) this.currentPage = maxPage;
|
||||
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||
const end = Math.min(start + this.itemsPerPage, total);
|
||||
|
||||
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
|
||||
this.elements.endIdx.textContent = end;
|
||||
this.elements.totalCount.textContent = total;
|
||||
|
||||
// Update Text (Use Translation)
|
||||
if (window.i18n && document.getElementById('pagination-controls')) {
|
||||
const text = window.i18n.t('common.table.showing', {
|
||||
start: total === 0 ? 0 : start + 1,
|
||||
end: end,
|
||||
total: total
|
||||
});
|
||||
// Find and update the text node if possible
|
||||
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
|
||||
if(container) {
|
||||
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
|
||||
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
|
||||
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
|
||||
}
|
||||
}
|
||||
|
||||
this.elements.body.innerHTML = '';
|
||||
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
|
||||
|
||||
this.elements.prevBtn.disabled = this.currentPage === 1;
|
||||
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
|
||||
|
||||
if (this.elements.pageNumbers) {
|
||||
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
|
||||
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
|
||||
}
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new TableManager(document.querySelectorAll('.table-row-item'), 15);
|
||||
if (typeof SimpleDataTable !== 'undefined') {
|
||||
new SimpleDataTable('#report-table', {
|
||||
itemsPerPage: 15,
|
||||
searchable: true,
|
||||
pagination: true,
|
||||
// Add Filter for Status Column (Index 1)
|
||||
filters: [
|
||||
{ index: 1, label: 'Status: All' }
|
||||
]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function exportReport(type) {
|
||||
const url = '/<?= $session ?>/reports/selling/export/' + type;
|
||||
const btn = document.querySelector('.dropdown-toggle');
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
// Show Loading State
|
||||
btn.innerHTML = `<i data-lucide="loader-2" class="w-4 h-4 mr-2 animate-spin"></i> Processing...`;
|
||||
lucide.createIcons();
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Export Failed: ' + data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = `selling-report-<?= date('Y-m-d') ?>-${type}.` + (type === 'csv' ? 'csv' : 'xlsx');
|
||||
|
||||
if (type === 'csv') {
|
||||
// Convert JSON to CSV
|
||||
const header = Object.keys(data[0]);
|
||||
const csv = [
|
||||
header.join(','), // header row first
|
||||
...data.map(row => header.map(fieldName => JSON.stringify(row[fieldName])).join(','))
|
||||
].join('\r\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('hidden', '');
|
||||
a.setAttribute('href', url);
|
||||
a.setAttribute('download', filename);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
else if (type === 'xlsx') {
|
||||
// Use SheetJS for Real Excel
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Selling Report");
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export Error:', error);
|
||||
alert('Failed to export data. Check console for details.');
|
||||
} finally {
|
||||
// Restore Button
|
||||
btn.innerHTML = originalText;
|
||||
lucide.createIcons();
|
||||
document.getElementById('export-menu').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
|
||||
@@ -21,7 +21,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<!-- Spacer -->
|
||||
</div>
|
||||
<div class="flex gap-2 w-full md:w-auto">
|
||||
<button onclick="openModal('addModal')" class="btn btn-primary w-full md:w-auto">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -40,7 +40,12 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<tbody id="table-body">
|
||||
<?php if (!empty($rules)): ?>
|
||||
<?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>
|
||||
<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>
|
||||
@@ -57,7 +62,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</td>
|
||||
<td class="text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||
<button onclick="editRule(<?= htmlspecialchars(json_encode($rule)) ?>)" class="btn-icon" title="Edit">
|
||||
<button onclick="openCorsModal(this.closest('tr'))" class="btn-icon" title="Edit">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<form action="/settings/api-cors/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('settings.cors_rule_deleted') : 'Delete CORS Rule?', 'Are you sure you want to delete the CORS rule for <?= htmlspecialchars($rule['origin']) ?>?', 'Delete', 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||
@@ -85,136 +90,71 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<div id="addModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('addModal')"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
||||
<div class="card shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold" data-i18n="settings.add_rule">Add CORS Rule</h3>
|
||||
<button onclick="closeModal('addModal')" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form action="/settings/api-cors/store" method="POST" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
|
||||
<input type="text" name="origin" class="form-control" placeholder="https://example.com or *" required>
|
||||
<p class="text-xs text-orange-500 dark:text-orange-400 mt-1 font-medium">Use * for all origins (not recommended for production).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox" <?= in_array($m, ['GET', 'POST']) ? 'checked' : '' ?>>
|
||||
<span class="text-sm"><?= $m ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
|
||||
<input type="text" name="headers" class="form-control" value="*" placeholder="Content-Type, Authorization, *">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
|
||||
<input type="number" name="max_age" class="form-control" value="3600">
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div id="editModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('editModal')"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
||||
<div class="card shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold" data-i18n="settings.edit_rule">Edit CORS Rule</h3>
|
||||
<button onclick="closeModal('editModal')" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form action="/settings/api-cors/update" method="POST" class="space-y-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.origin">Origin</label>
|
||||
<input type="text" name="origin" id="edit_origin" class="form-control" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" data-i18n="settings.methods">Allowed Methods</label>
|
||||
<div class="grid grid-cols-3 gap-2" id="edit_methods_container">
|
||||
<?php foreach(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'] as $m): ?>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" name="methods[]" value="<?= $m ?>" class="form-checkbox edit-method-check" data-method="<?= $m ?>">
|
||||
<span class="text-sm"><?= $m ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.headers">Allowed Headers</label>
|
||||
<input type="text" name="headers" id="edit_headers" class="form-control">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="settings.max_age">Max Age (seconds)</label>
|
||||
<input type="number" name="max_age" id="edit_max_age" class="form-control">
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-i18n="common.save">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
const content = modal.querySelector('.modal-content');
|
||||
modal.classList.remove('hidden');
|
||||
async function openCorsModal(row = null) {
|
||||
const isEdit = !!row;
|
||||
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');
|
||||
const template = document.getElementById('cors-form-template').innerHTML;
|
||||
const saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||
|
||||
// Use double requestAnimationFrame to ensure the browser has painted the hidden->block change
|
||||
// before we trigger the opacity/transform transitions.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.remove('opacity-0');
|
||||
content.classList.remove('scale-95', 'opacity-0');
|
||||
content.classList.add('scale-100', 'opacity-100');
|
||||
const preConfirmFn = () => {
|
||||
const form = document.getElementById('cors-form');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
Mivo.modal.form(title, template, saveBtn, preConfirmFn, onOpenedFn);
|
||||
}
|
||||
</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'; ?>
|
||||
|
||||
@@ -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">
|
||||
<!-- Spacer or Breadcrumbs if needed -->
|
||||
</div>
|
||||
<a href="/settings/add" class="btn btn-primary w-full md:w-auto">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add Router
|
||||
</a>
|
||||
<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> <span data-i18n="routers.add_router_title">Add Router</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if (empty($routers)): ?>
|
||||
@@ -34,9 +34,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
<h3 class="text-lg font-medium mb-2">No routers configured</h3>
|
||||
<p class="text-accents-5 mb-6 max-w-sm mx-auto">Connect your first MikroTik router to start managing hotspots and vouchers.</p>
|
||||
<a href="/settings/add" class="btn btn-primary">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Connect Router
|
||||
</a>
|
||||
<button onclick="openRouterModal('add')" class="btn btn-primary">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <span data-i18n="routers.add_router_title">Connect Router</span>
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-container">
|
||||
@@ -53,7 +53,17 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</thead>
|
||||
<tbody>
|
||||
<?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>
|
||||
<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">
|
||||
@@ -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">
|
||||
Open
|
||||
</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>
|
||||
</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">
|
||||
<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">
|
||||
@@ -98,13 +108,239 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<div class="text-sm text-accents-5">
|
||||
Showing all <?= count($routers) ?> stored sessions
|
||||
</div>
|
||||
<a href="/settings/add" class="btn btn-primary btn-sm w-full sm:w-auto justify-center">
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> Add New
|
||||
</a>
|
||||
<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> <span data-i18n="routers.add_router_title">Add New</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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'; ?>
|
||||
|
||||
@@ -22,8 +22,8 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<!-- Upload Section -->
|
||||
<section>
|
||||
<div class="card p-8 border-dashed border-2 bg-accents-1 hover:bg-background transition-colors text-center relative group">
|
||||
<form action="/settings/logos/upload" method="POST" enctype="multipart/form-data" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.svg,.gif" onchange="this.form.submit()" class="form-control-file">
|
||||
<form 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="block w-full h-full opacity-0 cursor-pointer">
|
||||
</form>
|
||||
|
||||
<div class="flex flex-col items-center justify-center pointer-events-none">
|
||||
|
||||
129
app/Views/settings/plugins.php
Normal file
129
app/Views/settings/plugins.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
// Plugins View
|
||||
$title = "Plugins";
|
||||
$no_main_container = true;
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<!-- Sub-Navbar Navigation -->
|
||||
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
|
||||
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.plugins">Plugins</h1>
|
||||
<p class="text-accents-5 mt-2" data-i18n="settings.plugins_desc">Manage and extend functionality with plugins.</p>
|
||||
</div>
|
||||
<button onclick="openUploadModal()" class="btn btn-primary">
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
<span data-i18n="settings.upload_plugin">Upload Plugin</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
|
||||
|
||||
<div class="card overflow-hidden p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs text-accents-5 uppercase bg-accents-1/50 border-b border-accents-2 font-semibold tracking-wider">
|
||||
<tr>
|
||||
<th class="px-6 py-4 w-[250px]" data-i18n="common.name">Name</th>
|
||||
<th class="px-6 py-4" data-i18n="common.description">Description</th>
|
||||
<th class="px-6 py-4 w-[100px]" data-i18n="common.version">Version</th>
|
||||
<th class="px-6 py-4 w-[150px]" data-i18n="common.author">Author</th>
|
||||
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.status">Status</th>
|
||||
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-accents-2">
|
||||
<?php if(empty($plugins)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-12 text-center text-accents-5">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="p-3 rounded-full bg-accents-1">
|
||||
<i data-lucide="package-search" class="w-6 h-6 text-accents-4"></i>
|
||||
</div>
|
||||
<span class="font-medium" data-i18n="settings.no_plugins">No plugins installed</span>
|
||||
<span class="text-xs" data-i18n="settings.no_plugins_desc">Upload a .zip file to get started.</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach($plugins as $plugin): ?>
|
||||
<tr class="group hover:bg-accents-1/30 transition-colors">
|
||||
<td class="px-6 py-4 font-medium text-foreground">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-8 w-8 rounded bg-primary/10 flex items-center justify-center text-primary">
|
||||
<i data-lucide="plug" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span><?= htmlspecialchars($plugin['name']) ?></span>
|
||||
<span class="text-[10px] text-accents-4 font-normal font-mono"><?= htmlspecialchars($plugin['id']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-accents-6">
|
||||
<?= htmlspecialchars($plugin['description']) ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-accents-6 font-mono text-xs">
|
||||
<?= htmlspecialchars($plugin['version']) ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-accents-6">
|
||||
<?= htmlspecialchars($plugin['author']) ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
Active
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<form action="/settings/plugins/delete" method="POST" class="inline" onsubmit="event.preventDefault();
|
||||
const title = window.i18n ? window.i18n.t('settings.delete_plugin') : 'Delete Plugin?';
|
||||
const msg = window.i18n ? window.i18n.t('settings.delete_plugin_confirm', {name: '<?= htmlspecialchars($plugin['name']) ?>'}) : 'Delete this plugin?';
|
||||
|
||||
Mivo.confirm(title, msg, window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => {
|
||||
if(res) this.submit();
|
||||
});">
|
||||
<input type="hidden" name="plugin_id" value="<?= htmlspecialchars($plugin['id']) ?>">
|
||||
<button type="submit" class="btn-icon-danger" title="Delete">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openUploadModal() {
|
||||
const title = window.i18n ? window.i18n.t('settings.upload_plugin') : 'Upload Plugin';
|
||||
const html = `
|
||||
<form id="upload-plugin-form" action="/settings/plugins/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
|
||||
<div class="text-sm text-accents-5">
|
||||
<p class="mb-4" data-i18n="settings.upload_plugin_desc">Select a plugin .zip file to install.</p>
|
||||
<input type="file" name="plugin_file" accept=".zip" required class="form-control-file w-full">
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
Mivo.modal.form(title, html, window.i18n ? window.i18n.t('common.install') : 'Install', () => {
|
||||
const form = document.getElementById('upload-plugin-form');
|
||||
if (form.reportValidity()) {
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
@@ -12,17 +12,24 @@ $initialContent = $template['content'] ?? '<div style="border: 1px solid #000; p
|
||||
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">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 flex-shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/settings/templates" class="text-accents-5 hover:text-foreground transition-colors">
|
||||
<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>
|
||||
</a>
|
||||
<h1 class="text-xl font-bold tracking-tight text-foreground"><?= $title ?></h1>
|
||||
</div>
|
||||
|
||||
<form id="templateForm" action="<?= $isEdit ? '/settings/templates/update' : '/settings/templates/store' ?>" method="POST" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 w-full lg:w-auto">
|
||||
<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): ?>
|
||||
<input type="hidden" name="id" value="<?= $template['id'] ?>">
|
||||
<?php endif; ?>
|
||||
@@ -64,7 +71,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</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>
|
||||
|
||||
<!-- Right: Preview -->
|
||||
@@ -84,6 +93,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrious.min.js"></script>
|
||||
<script src="/assets/js/vendor/editor.bundle.js"></script>
|
||||
</div>
|
||||
|
||||
<!-- Documentation Modal -->
|
||||
@@ -104,6 +114,18 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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">
|
||||
@@ -201,148 +223,43 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Editor Logic ---
|
||||
const editor = document.getElementById('codeEditor');
|
||||
// --- Editor Logic (CodeMirror 6) ---
|
||||
const textarea = document.getElementById('codeEditor');
|
||||
const container = document.getElementById('editorContainer');
|
||||
const preview = document.getElementById('previewContainer');
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
// History Stack for Undo/Redo
|
||||
let historyStack = [];
|
||||
let redoStack = [];
|
||||
let isTyping = false;
|
||||
let typingTimer = null;
|
||||
let cmView = null;
|
||||
|
||||
// Initial State
|
||||
historyStack.push({ value: editor.value, selectionStart: 0, selectionEnd: 0 });
|
||||
|
||||
function saveState() {
|
||||
// Limit stack size
|
||||
if (historyStack.length > 50) historyStack.shift();
|
||||
|
||||
const lastState = historyStack[historyStack.length - 1];
|
||||
if (lastState && lastState.value === editor.value) return; // No change
|
||||
|
||||
historyStack.push({
|
||||
value: editor.value,
|
||||
selectionStart: editor.selectionStart,
|
||||
selectionEnd: editor.selectionEnd
|
||||
});
|
||||
redoStack = []; // Clear redo on new change
|
||||
}
|
||||
|
||||
// Debounced save for typing
|
||||
editor.addEventListener('input', (e) => {
|
||||
if (!isTyping) {
|
||||
// Save state *before* a burst of typing starts?
|
||||
// Actually usually we save *after*.
|
||||
// For robust undo: save state Before modification if possible, or assume previous state is safe.
|
||||
// Simplified: Save debounced.
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(saveState, 500);
|
||||
}
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// --- Keyboard Shortcuts (Undo/Redo, Tab, Enter) ---
|
||||
editor.addEventListener('keydown', function(e) {
|
||||
// Undo: Ctrl+Z
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
// Redo: Ctrl+Y or Ctrl+Shift+Z
|
||||
if (((e.ctrlKey || e.metaKey) && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
function initEditor() {
|
||||
if (typeof MivoEditor === 'undefined') {
|
||||
console.error('CodeMirror bundle not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: Insert/Remove Indent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
const val = this.value;
|
||||
const tabChar = " "; // 4 spaces
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Un-indent (naive single line)
|
||||
// TODO: Multiline support if needed. For now simple cursor unindent.
|
||||
// Checking previous chars
|
||||
// Not implemented for simplicity, just preventing focus loss.
|
||||
} else {
|
||||
// Insert Tab
|
||||
// Use setRangeText to preserve browser undo buffer if mixed usage?
|
||||
// But we have custom undo.
|
||||
this.value = val.substring(0, start) + tabChar + val.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + tabChar.length;
|
||||
saveState();
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
// Enter: Auto-indent checking previous line
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
const val = this.value;
|
||||
|
||||
// Find start of current line
|
||||
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
||||
const currentLine = val.substring(lineStart, start);
|
||||
|
||||
// Calculate indentation
|
||||
const match = currentLine.match(/^\s*/);
|
||||
const indent = match ? match[0] : '';
|
||||
|
||||
const insert = '\n' + indent;
|
||||
|
||||
this.value = val.substring(0, start) + insert + val.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + insert.length;
|
||||
|
||||
saveState(); // Immediate save on Enter
|
||||
cmView = MivoEditor.init({
|
||||
parent: container,
|
||||
initialValue: textarea.value,
|
||||
dark: isDark,
|
||||
onChange: (val) => {
|
||||
textarea.value = val;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
function undo() {
|
||||
if (historyStack.length > 1) { // Keep initial state
|
||||
const current = historyStack.pop();
|
||||
redoStack.push(current);
|
||||
|
||||
const prev = historyStack[historyStack.length - 1];
|
||||
editor.value = prev.value;
|
||||
editor.selectionStart = prev.selectionStart;
|
||||
editor.selectionEnd = prev.selectionEnd;
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (redoStack.length > 0) {
|
||||
const next = redoStack.pop();
|
||||
historyStack.push(next);
|
||||
|
||||
editor.value = next.value;
|
||||
editor.selectionStart = next.selectionStart;
|
||||
editor.selectionEnd = next.selectionEnd;
|
||||
updatePreview();
|
||||
}
|
||||
// Set focus
|
||||
cmView.focus();
|
||||
}
|
||||
|
||||
function insertVar(text) {
|
||||
saveState(); // Save state before insertion
|
||||
if (!cmView) return;
|
||||
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const val = editor.value;
|
||||
editor.value = val.substring(0, start) + text + val.substring(end);
|
||||
editor.selectionStart = editor.selectionEnd = start + text.length;
|
||||
editor.focus();
|
||||
|
||||
saveState(); // Save state after insertion
|
||||
updatePreview();
|
||||
const selection = cmView.state.selection.main;
|
||||
cmView.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: text },
|
||||
selection: { anchor: selection.from + text.length }
|
||||
});
|
||||
cmView.focus();
|
||||
}
|
||||
|
||||
// Live Preview Logic
|
||||
@@ -359,16 +276,16 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
'{{timelimit}}': ' 3 Hours',
|
||||
'{{datalimit}}': '500 MB',
|
||||
'{{profile}}': 'General',
|
||||
'{{comment}}': 'mikhmon',
|
||||
'{{hotspotname}}': 'Mikhmon Hotspot',
|
||||
'{{comment}}': 'mivo',
|
||||
'{{hotspotname}}': 'Mivo Hotspot',
|
||||
'{{num}}': '1',
|
||||
'{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder
|
||||
'{{dns_name}}': 'hotspot.mikhmon',
|
||||
'{{login_url}}': 'http://hotspot.mikhmon/login',
|
||||
'{{dns_name}}': 'hotspot.mivo',
|
||||
'{{login_url}}': 'http://hotspot.mivo/login',
|
||||
};
|
||||
|
||||
function updatePreview() {
|
||||
let content = editor.value;
|
||||
let content = textarea.value;
|
||||
|
||||
// 1. Handle {{logo 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;
|
||||
}
|
||||
|
||||
editor.addEventListener('input', updatePreview); // Handled by debouncer above too, but OK.
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initEditor();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// Theme Switch Recognition
|
||||
window.addEventListener('languageChanged', () => {
|
||||
// Not language, but theme toggle button often triggers layout shifts.
|
||||
// We might need a MutationObserver if we want to live-toggle CM theme.
|
||||
// For now, reload or manual re-init on theme toggle could work.
|
||||
});
|
||||
|
||||
// Watch for theme changes globally
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class' && mutation.target === document.documentElement) {
|
||||
// Theme changed
|
||||
// CodeMirror 6 themes are extensions, changing them requires re-configuring the state.
|
||||
// For simplicity, let's just re-init everything if theme changes.
|
||||
const newIsDark = document.documentElement.classList.contains('dark');
|
||||
if (cmView) {
|
||||
const content = cmView.state.doc.toString();
|
||||
container.innerHTML = '';
|
||||
cmView = null;
|
||||
textarea.value = content;
|
||||
initEditor();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
</script>
|
||||
|
||||
<?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">
|
||||
<!-- Spacer -->
|
||||
</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>
|
||||
<span data-i18n="settings.new_template">New Template</span>
|
||||
</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>
|
||||
</div>
|
||||
<iframe
|
||||
data-src="/settings/templates/preview/default"
|
||||
data-src="/settings/voucher-templates/preview/default"
|
||||
src="about:blank"
|
||||
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
||||
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>
|
||||
</div>
|
||||
<iframe
|
||||
data-src="/settings/templates/preview/<?= $tpl['id'] ?>"
|
||||
data-src="/settings/voucher-templates/preview/<?= $tpl['id'] ?>"
|
||||
src="about:blank"
|
||||
class="w-full h-full border-0 pointer-events-none opacity-0 transition-opacity duration-500"
|
||||
scrolling="no"
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
</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="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">
|
||||
@@ -12,7 +12,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<button onclick="location.reload()" class="btn btn-secondary">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
|
||||
</button>
|
||||
<button onclick="openModal('addModal')" class="btn btn-primary">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -54,7 +54,14 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
$status = ($task['disabled'] === 'true') ? 'disabled' : 'enabled';
|
||||
?>
|
||||
<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 ?>">
|
||||
|
||||
<td>
|
||||
@@ -72,7 +79,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</td>
|
||||
<td class="text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-2 table-actions-reveal">
|
||||
<button onclick="editTask(<?= htmlspecialchars(json_encode($task)) ?>)" class="btn-icon" title="Edit">
|
||||
<button onclick="openSchedulerModal('edit', this)" class="btn-icon" title="Edit">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<form action="/<?= $session ?>/system/scheduler/delete" method="POST" onsubmit="event.preventDefault(); Mivo.confirm(window.i18n ? window.i18n.t('system_tools.delete_task') : 'Delete Task?', window.i18n ? window.i18n.t('common.confirm_delete') : 'Are you sure you want to delete task <?= htmlspecialchars($task['name']) ?>?', window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => { if(res) this.submit(); });" class="inline">
|
||||
@@ -103,104 +110,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<div id="addModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('addModal')"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
||||
<div class="card shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold" data-i18n="system_tools.add_title">Add Scheduler Task</h3>
|
||||
<button onclick="closeModal('addModal')" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form action="/<?= $session ?>/system/scheduler/store" method="POST" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.name">Name</label>
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.interval">Interval</label>
|
||||
<input type="text" name="interval" class="form-control" value="1d 00:00:00" placeholder="1d 00:00:00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_date">Start Date</label>
|
||||
<input type="text" name="start_date" class="form-control" value="Jan/01/1970">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_time">Start Time</label>
|
||||
<input type="text" name="start_time" class="form-control" value="00:00:00">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.on_event">On Event (Script)</label>
|
||||
<textarea name="on_event" class="form-control font-mono text-xs h-24" placeholder="/system reboot"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.comment">Comment</label>
|
||||
<input type="text" name="comment" class="form-control">
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-i18n="system_tools.save_task">Save Task</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div id="editModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300" onclick="closeModal('editModal')"></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-lg transition-all duration-300 scale-95 opacity-0 modal-content">
|
||||
<div class="card shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold" data-i18n="system_tools.edit_title">Edit Scheduler Task</h3>
|
||||
<button onclick="closeModal('editModal')" class="text-accents-5 hover:text-foreground">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form action="/<?= $session ?>/system/scheduler/update" method="POST" class="space-y-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.name">Name</label>
|
||||
<input type="text" name="name" id="edit_name" class="form-control" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.interval">Interval</label>
|
||||
<input type="text" name="interval" id="edit_interval" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_date">Start Date</label>
|
||||
<input type="text" name="start_date" id="edit_start_date" class="form-control">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.start_time">Start Time</label>
|
||||
<input type="text" name="start_time" id="edit_start_time" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.on_event">On Event (Script)</label>
|
||||
<textarea name="on_event" id="edit_on_event" class="form-control font-mono text-xs h-24"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" data-i18n="system_tools.comment">Comment</label>
|
||||
<input type="text" name="comment" id="edit_comment" class="form-control">
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="button" onclick="closeModal('editModal')" class="btn btn-secondary mr-2" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-i18n="system_tools.update_task">Update Task</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class TableManager {
|
||||
@@ -256,7 +166,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
|
||||
update() {
|
||||
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;
|
||||
|
||||
@@ -308,42 +218,49 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
const content = modal.querySelector('.modal-content');
|
||||
function openSchedulerModal(mode, btn = null) {
|
||||
const template = document.getElementById('scheduler-form-template').innerHTML;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
// Force reflow
|
||||
void modal.offsetWidth;
|
||||
let title = window.i18n ? window.i18n.t('system_tools.add_title') : 'Add Scheduler Task';
|
||||
let saveBtn = window.i18n ? window.i18n.t('common.save') : 'Save';
|
||||
|
||||
modal.classList.remove('opacity-0');
|
||||
content.classList.remove('scale-95', 'opacity-0');
|
||||
content.classList.add('scale-100', 'opacity-100');
|
||||
}
|
||||
if (mode === 'edit') {
|
||||
title = window.i18n ? window.i18n.t('system_tools.edit_title') : 'Edit Scheduler Task';
|
||||
saveBtn = window.i18n ? window.i18n.t('common.forms.save_changes') : 'Save Changes';
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
const content = modal.querySelector('.modal-content');
|
||||
const preConfirmFn = () => {
|
||||
const form = Swal.getHtmlContainer().querySelector('form');
|
||||
if(form.reportValidity()) {
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
modal.classList.add('opacity-0');
|
||||
content.classList.remove('scale-100', 'opacity-100');
|
||||
content.classList.add('scale-95', 'opacity-0');
|
||||
const onOpenedFn = (popup) => {
|
||||
const form = popup.querySelector('form');
|
||||
|
||||
setTimeout(() => {
|
||||
modal.classList.add('hidden');
|
||||
}, 300); // Match duration-300
|
||||
}
|
||||
if (mode === 'edit' && btn) {
|
||||
const row = btn.closest('tr');
|
||||
form.action = "/<?= htmlspecialchars($session) ?>/system/scheduler/update";
|
||||
|
||||
function editTask(task) {
|
||||
document.getElementById('edit_id').value = task['.id'];
|
||||
document.getElementById('edit_name').value = task['name'];
|
||||
document.getElementById('edit_interval').value = task['interval'];
|
||||
document.getElementById('edit_start_date').value = task['start-date'];
|
||||
document.getElementById('edit_start_time').value = task['start-time'];
|
||||
document.getElementById('edit_on_event').value = task['on-event'];
|
||||
document.getElementById('edit_comment').value = task['comment'] ?? '';
|
||||
// Populate Hidden ID
|
||||
const idInput = form.querySelector('#form-id');
|
||||
idInput.disabled = false;
|
||||
idInput.value = row.dataset.id;
|
||||
|
||||
openModal('editModal');
|
||||
// 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', () => {
|
||||
@@ -351,4 +268,40 @@ function editTask(task) {
|
||||
});
|
||||
</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'; ?>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dyzulk/mivo",
|
||||
"name": "mivodev/mivo",
|
||||
"description": "MIVO - Modern Mikrotik Voucher Management System",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "DyzulkDev",
|
||||
"email": "dev@dyzulk.com"
|
||||
"name": "MivoDev",
|
||||
"email": "mivo@dev.dyzulk.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
|
||||
53
deploy.ps1
53
deploy.ps1
@@ -1,53 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Configuration
|
||||
$RemotePath = "/www/wwwroot/app.mivo.dyzulk.com"
|
||||
|
||||
Write-Host "Starting Deployment to app.mivo.dyzulk.com..." -ForegroundColor Green
|
||||
|
||||
# 1. Build Assets
|
||||
Write-Host "Building assets..." -ForegroundColor Cyan
|
||||
cmd /c "npm run build"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Build failed!"
|
||||
}
|
||||
|
||||
# 2. Create Archive
|
||||
Write-Host "Creating deployment package..." -ForegroundColor Cyan
|
||||
# Excluding potential garbage
|
||||
$excludeParams = @("--exclude", "node_modules", "--exclude", ".git", "--exclude", ".github", "--exclude", "temp_debug", "--exclude", "deploy.ps1", "--exclude", "*.tar.gz")
|
||||
tar -czf deploy_package.tar.gz @excludeParams app public routes mivo src package.json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to create archive!"
|
||||
}
|
||||
|
||||
# 3. Upload
|
||||
Write-Host "Uploading to server ($RemotePath)..." -ForegroundColor Cyan
|
||||
scp deploy_package.tar.gz "aapanel:$RemotePath/"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "SCP upload failed!"
|
||||
}
|
||||
|
||||
# 4. Extract and Cleanup on Server
|
||||
Write-Host "Extracting and configuring permissions..." -ForegroundColor Cyan
|
||||
# Commands:
|
||||
# 1. cd to remote path
|
||||
# 2. Extract
|
||||
# 3. Set ownership to www:www
|
||||
# 4. Set mivo executable
|
||||
# 5. Set public folder to 755 (Laravel recommendation)
|
||||
# 6. Cleanup archive
|
||||
$remoteCommands = "cd $RemotePath && tar -xzf deploy_package.tar.gz && chown -R www:www . && chmod +x mivo && chmod -R 755 public && rm deploy_package.tar.gz"
|
||||
|
||||
ssh aapanel $remoteCommands
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Remote deployment failed!"
|
||||
}
|
||||
|
||||
# 5. Local Cleanup
|
||||
Write-Host "Cleaning up local package..." -ForegroundColor Cyan
|
||||
if (Test-Path deploy_package.tar.gz) {
|
||||
Remove-Item deploy_package.tar.gz
|
||||
}
|
||||
|
||||
Write-Host "Deployment successfully completed!" -ForegroundColor Green
|
||||
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
|
||||
25
docker/entrypoint.sh
Normal file
25
docker/entrypoint.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Ensure Database directory exists
|
||||
mkdir -p /var/www/html/app/Database
|
||||
|
||||
# Fix permissions for the Database directory
|
||||
# This is crucial for SQLite when volumes are mounted from host
|
||||
if [ -d "/var/www/html/app/Database" ]; then
|
||||
chown -R www-data:www-data /var/www/html/app/Database
|
||||
chmod -R 775 /var/www/html/app/Database
|
||||
fi
|
||||
|
||||
# Also ensure .env is writable if it exists, or create it from example
|
||||
if [ ! -f "/var/www/html/.env" ] && [ -f "/var/www/html/.env.example" ]; then
|
||||
cp /var/www/html/.env.example /var/www/html/.env
|
||||
chown www-data:www-data /var/www/html/.env
|
||||
fi
|
||||
|
||||
if [ -f "/var/www/html/.env" ]; then
|
||||
chmod 664 /var/www/html/.env
|
||||
fi
|
||||
|
||||
# Execute the command passed to docker run (usually supervisor)
|
||||
exec "$@"
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user