Compare commits
4
Commits
a2c35c5915
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2fac9cdf | ||
|
|
84cd474bcb | ||
|
|
7198895444 | ||
|
|
5c699a221c |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# webappAIO LXC Bootstrap
|
# webappAIO LXC Bootstrap
|
||||||
|
|
||||||
Spin up a fully configured development container in one step: SSH, Apache/PHP/MariaDB or SQLite, a Laravel API backend (`backend/`) + React/Vite frontend (`frontend/`), a self-hosted Mosquitto MQTT broker (Docker), [code-server](https://code-server.dev), and [Claude Code](https://docs.claude.com/en/docs/claude-code) — all from a single command pasted into a fresh Proxmox LXC.
|
Spin up a fully configured development container in one step: SSH, Apache/PHP/SQLite, the Laravel API backend (`backend/`) + React/Vite frontend (`frontend/`) from this repo, a self-hosted Mosquitto MQTT broker (Docker), [code-server](https://code-server.dev), and [Claude Code](https://docs.claude.com/en/docs/claude-code) — all from a single command pasted into a fresh Proxmox LXC.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -13,26 +13,18 @@ curl -fsSL https://git.innovator.bh/ghassan/webappAIO/raw/branch/main/bootstrap.
|
|||||||
You'll be prompted for:
|
You'll be prompted for:
|
||||||
|
|
||||||
- Project name
|
- Project name
|
||||||
- Git repository to clone (leave blank to scaffold a bare backend/frontend pair instead — see below)
|
|
||||||
- Database: `mysql` or `sqlite`
|
|
||||||
- MariaDB username/password/database (only if you chose `mysql`)
|
|
||||||
- Seeded Super Admin email/username/password
|
- Seeded Super Admin email/username/password
|
||||||
|
|
||||||
|
The script always clones this repo (`https://git.innovator.bh/ghassan/webappAIO.git`) — the full RBAC + 2FA + MQTT-realtime + Users-management app described in [`claude-project-prompt.md`](./claude-project-prompt.md) — and always uses SQLite. Neither is prompted for.
|
||||||
|
|
||||||
When it finishes you'll have a running API, a Mosquitto broker, code-server, and Claude Code installed, plus a Super Admin account ready to log in with.
|
When it finishes you'll have a running API, a Mosquitto broker, code-server, and Claude Code installed, plus a Super Admin account ready to log in with.
|
||||||
|
|
||||||
### Blank `GIT_REPO` vs. cloning this repo
|
|
||||||
|
|
||||||
- **Blank** scaffolds a fresh Laravel install in `backend/` and a fresh Vite React-TS app in `frontend/`, with the core packages already required (Sanctum, `spatie/laravel-permission`, `pragmarx/google2fa-laravel`, `php-mqtt/laravel-client`) — the foundation to build the rest of [`claude-project-prompt.md`](./claude-project-prompt.md)'s architecture on top of, not the finished app.
|
|
||||||
- **`GIT_REPO=https://git.innovator.bh/ghassan/webappAIO.git`** clones this repo, which already contains the full RBAC + 2FA + MQTT-realtime + Users-management implementation described in that prompt.
|
|
||||||
|
|
||||||
## Fully unattended install
|
## Fully unattended install
|
||||||
|
|
||||||
Skip every prompt by exporting variables before running the script (useful for scripted LXC provisioning):
|
Skip every prompt by exporting variables before running the script (useful for scripted LXC provisioning):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_NAME=myapp \
|
APP_NAME=myapp \
|
||||||
GIT_REPO="" \
|
|
||||||
DB_TYPE=sqlite \
|
|
||||||
SUPER_ADMIN_EMAIL=[email protected] \
|
SUPER_ADMIN_EMAIL=[email protected] \
|
||||||
SUPER_ADMIN_USERNAME=admin \
|
SUPER_ADMIN_USERNAME=admin \
|
||||||
SUPER_ADMIN_PASSWORD='change-me' \
|
SUPER_ADMIN_PASSWORD='change-me' \
|
||||||
@@ -44,11 +36,6 @@ bash bootstrap.sh
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `APP_NAME` | Project directory name under `/var/www` | `laravel-app` |
|
| `APP_NAME` | Project directory name under `/var/www` | `laravel-app` |
|
||||||
| `GIT_REPO` | Git URL to clone; blank scaffolds a bare backend/frontend pair | *(blank)* |
|
|
||||||
| `DB_TYPE` | `mysql` or `sqlite` | `sqlite` |
|
|
||||||
| `DB_USER` | MariaDB username (`mysql` only) | `laravel_user` |
|
|
||||||
| `DB_PASS` | MariaDB password (`mysql` only) | random |
|
|
||||||
| `DB_NAME` | MariaDB database name (`mysql` only) | `laravel_db` |
|
|
||||||
| `SUPER_ADMIN_EMAIL` | Seeded Super Admin email | `[email protected]` |
|
| `SUPER_ADMIN_EMAIL` | Seeded Super Admin email | `[email protected]` |
|
||||||
| `SUPER_ADMIN_USERNAME` | Seeded Super Admin username | `superadmin` |
|
| `SUPER_ADMIN_USERNAME` | Seeded Super Admin username | `superadmin` |
|
||||||
| `SUPER_ADMIN_PASSWORD` | Seeded Super Admin password | random |
|
| `SUPER_ADMIN_PASSWORD` | Seeded Super Admin password | random |
|
||||||
@@ -61,11 +48,11 @@ bash bootstrap.sh
|
|||||||
1. **Base system** — `apt update/upgrade`, installs `sudo curl git wget unzip`.
|
1. **Base system** — `apt update/upgrade`, installs `sudo curl git wget unzip`.
|
||||||
2. **SSH** — installs and enables `openssh-server`; on TurnKey Core containers enables `AllowTcpForwarding`, on other distros enables `PermitRootLogin yes` (needed for VS Code Remote-SSH / port tunneling).
|
2. **SSH** — installs and enables `openssh-server`; on TurnKey Core containers enables `AllowTcpForwarding`, on other distros enables `PermitRootLogin yes` (needed for VS Code Remote-SSH / port tunneling).
|
||||||
3. **Docker + Mosquitto** — installs Docker Engine + Compose plugin, generates per-role Mosquitto broker credentials, brings up the `mosquitto` container (MQTT on 1883, WebSockets on 9001).
|
3. **Docker + Mosquitto** — installs Docker Engine + Compose plugin, generates per-role Mosquitto broker credentials, brings up the `mosquitto` container (MQTT on 1883, WebSockets on 9001).
|
||||||
4. **Laravel + React stack** — Apache, PHP + required extensions, MariaDB or SQLite, Composer, Node.js, clones or scaffolds `backend/` (Laravel) and `frontend/` (Vite React-TS), sets permissions, configures an Apache vhost pointing at `backend/public`, generates `.env` (DB, MQTT, Super Admin, Sanctum/CORS), runs `key:generate`, `migrate`, and `db:seed`.
|
4. **Laravel + React stack** — Apache, PHP + SQLite extension, Composer, Node.js, clones this repo into `backend/` (Laravel) and `frontend/` (Vite React-TS), sets permissions, configures an Apache vhost pointing at `backend/public`, generates `.env` (DB, MQTT, Super Admin, Sanctum/CORS), runs `key:generate`, `migrate`, and `db:seed`.
|
||||||
5. **Queue worker** — installs and starts a systemd unit (`<app-name>-queue`) running `php artisan queue:work`, required for MQTT events to actually publish.
|
5. **Queue worker** — installs and starts a systemd unit (`<app-name>-queue`) running `php artisan queue:work`, required for MQTT events to actually publish.
|
||||||
6. **code-server** — browser-based VS Code, so you can edit the app over SSH without a local editor.
|
6. **code-server** — browser-based VS Code, so you can edit the app over SSH without a local editor.
|
||||||
7. **Claude Code** — `npm install -g @anthropic-ai/claude-code`.
|
7. **Claude Code** — `npm install -g @anthropic-ai/claude-code`.
|
||||||
8. Prints a summary with the app path, DB type, container IP, Super Admin credentials, and next steps.
|
8. Prints a summary with the app path, container IP, Super Admin credentials, and next steps.
|
||||||
|
|
||||||
## After install
|
## After install
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Audit\AuditLogger;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class BackupController extends Controller
|
||||||
|
{
|
||||||
|
private const SQLITE_HEADER = "SQLite format 3\000";
|
||||||
|
|
||||||
|
public function download(Request $request, AuditLogger $audit)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('system.manage'), 403);
|
||||||
|
|
||||||
|
$path = config('database.connections.sqlite.database');
|
||||||
|
abort_unless($path && is_file($path), 404, 'No database file to back up.');
|
||||||
|
|
||||||
|
$audit->log($request->user(), 'system.backup_downloaded');
|
||||||
|
|
||||||
|
return response()->download($path, 'backup-'.now()->format('Y-m-d-His').'.sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Request $request, AuditLogger $audit)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('system.manage'), 403);
|
||||||
|
|
||||||
|
$request->validate(['backup' => ['required', 'file']]);
|
||||||
|
|
||||||
|
$uploaded = $request->file('backup');
|
||||||
|
$header = file_get_contents($uploaded->getRealPath(), false, null, 0, 16);
|
||||||
|
|
||||||
|
if ($header !== self::SQLITE_HEADER) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'backup' => 'That file is not a valid SQLite database.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = config('database.connections.sqlite.database');
|
||||||
|
|
||||||
|
// Release Laravel's open handle on the current file before swapping it.
|
||||||
|
DB::disconnect();
|
||||||
|
|
||||||
|
$safetyCopy = dirname($path).'/pre-restore-'.now()->format('Y-m-d-His').'.sqlite';
|
||||||
|
if (is_file($path)) {
|
||||||
|
copy($path, $safetyCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploaded->move(dirname($path), basename($path));
|
||||||
|
|
||||||
|
$audit->log($request->user(), 'system.backup_restored');
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use PhpMqtt\Client\ConnectionSettings;
|
||||||
|
use PhpMqtt\Client\MqttClient;
|
||||||
|
|
||||||
class MqttController extends Controller
|
class MqttController extends Controller
|
||||||
{
|
{
|
||||||
@@ -25,4 +27,51 @@ class MqttController extends Controller
|
|||||||
'password' => $credentials['password'],
|
'password' => $credentials['password'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only broker configuration + a live reachability check, for the
|
||||||
|
* MQTT settings page. Never exposes the publisher/role credentials.
|
||||||
|
*/
|
||||||
|
public function settings(Request $request)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('system.manage'), 403);
|
||||||
|
|
||||||
|
$host = config('mqtt-client.connections.default.host');
|
||||||
|
$port = config('mqtt-client.connections.default.port');
|
||||||
|
$tls = (bool) config('mqtt-client.connections.default.connection_settings.tls.enabled');
|
||||||
|
$username = config('mqtt-client.connections.default.connection_settings.auth.username');
|
||||||
|
$password = config('mqtt-client.connections.default.connection_settings.auth.password');
|
||||||
|
|
||||||
|
$connected = $this->checkBrokerReachable($host, $port, $tls, $username, $password);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'host' => $host,
|
||||||
|
'port' => $port,
|
||||||
|
'ws_url' => config('mqtt.ws_url'),
|
||||||
|
'tls_enabled' => $tls,
|
||||||
|
'connected' => $connected,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkBrokerReachable(?string $host, ?int $port, bool $tls, ?string $username, ?string $password): bool
|
||||||
|
{
|
||||||
|
if (! $host || ! $port) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = new MqttClient($host, $port, 'health-check-'.uniqid());
|
||||||
|
$settings = (new ConnectionSettings)
|
||||||
|
->setConnectTimeout(2)
|
||||||
|
->setUseTls($tls)
|
||||||
|
->setUsername($username)
|
||||||
|
->setPassword($password);
|
||||||
|
$client->connect($settings, true);
|
||||||
|
$client->disconnect();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,4 +17,18 @@ class RoleController extends Controller
|
|||||||
// seeding/tinker, never assignable through the Users module.
|
// seeding/tinker, never assignable through the Users module.
|
||||||
return RoleResource::collection(Role::where('name', '!=', 'super-admin')->orderBy('name')->get());
|
return RoleResource::collection(Role::where('name', '!=', 'super-admin')->orderBy('name')->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of every role (including Super Admin) and its
|
||||||
|
* permission set — for the Roles page, distinct from index() which
|
||||||
|
* feeds the assignable-role dropdown in the Users module.
|
||||||
|
*/
|
||||||
|
public function overview(Request $request)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('roles.view'), 403);
|
||||||
|
|
||||||
|
$roles = Role::with('permissions')->orderBy('name')->get();
|
||||||
|
|
||||||
|
return RoleResource::collection($roles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ class RoleResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
|
'permissions' => $this->whenLoaded('permissions', fn () => $this->permissions->pluck('name')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'roles.view', 'roles.assign',
|
'roles.view', 'roles.assign',
|
||||||
'dashboard.view',
|
'dashboard.view',
|
||||||
'audit.view',
|
'audit.view',
|
||||||
|
'system.manage',
|
||||||
];
|
];
|
||||||
|
|
||||||
private const ROLE_PERMISSIONS = [
|
private const ROLE_PERMISSIONS = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\AuthController;
|
use App\Http\Controllers\Api\AuthController;
|
||||||
|
use App\Http\Controllers\Api\BackupController;
|
||||||
use App\Http\Controllers\Api\DashboardController;
|
use App\Http\Controllers\Api\DashboardController;
|
||||||
use App\Http\Controllers\Api\MqttController;
|
use App\Http\Controllers\Api\MqttController;
|
||||||
use App\Http\Controllers\Api\RoleController;
|
use App\Http\Controllers\Api\RoleController;
|
||||||
@@ -19,8 +20,10 @@ Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
|||||||
Route::post('/auth/2fa/disable', [AuthController::class, 'disableTwoFactor']);
|
Route::post('/auth/2fa/disable', [AuthController::class, 'disableTwoFactor']);
|
||||||
|
|
||||||
Route::get('/mqtt/token', [MqttController::class, 'token']);
|
Route::get('/mqtt/token', [MqttController::class, 'token']);
|
||||||
|
Route::get('/mqtt/settings', [MqttController::class, 'settings']);
|
||||||
|
|
||||||
Route::get('/roles', [RoleController::class, 'index']);
|
Route::get('/roles', [RoleController::class, 'index']);
|
||||||
|
Route::get('/roles/overview', [RoleController::class, 'overview']);
|
||||||
|
|
||||||
Route::get('/users', [UserController::class, 'index']);
|
Route::get('/users', [UserController::class, 'index']);
|
||||||
Route::post('/users', [UserController::class, 'store']);
|
Route::post('/users', [UserController::class, 'store']);
|
||||||
@@ -31,5 +34,8 @@ Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
|||||||
|
|
||||||
Route::get('/dashboard/stats', [DashboardController::class, 'stats']);
|
Route::get('/dashboard/stats', [DashboardController::class, 'stats']);
|
||||||
Route::get('/dashboard/activity', [DashboardController::class, 'activity']);
|
Route::get('/dashboard/activity', [DashboardController::class, 'activity']);
|
||||||
|
|
||||||
|
Route::get('/backup/download', [BackupController::class, 'download']);
|
||||||
|
Route::post('/backup/restore', [BackupController::class, 'restore']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+63
-88
@@ -10,22 +10,15 @@
|
|||||||
#
|
#
|
||||||
# All prompts can be skipped by exporting env vars before running, e.g.:
|
# All prompts can be skipped by exporting env vars before running, e.g.:
|
||||||
#
|
#
|
||||||
# APP_NAME=myapp GIT_REPO="" DB_TYPE=sqlite \
|
# APP_NAME=myapp SUPER_ADMIN_PASSWORD='change-me' \
|
||||||
# bash bootstrap.sh
|
# bash bootstrap.sh
|
||||||
#
|
#
|
||||||
# GIT_REPO blank scaffolds a bare Laravel (backend/) + Vite React (frontend/)
|
# This script always clones https://git.innovator.bh/ghassan/webappAIO.git
|
||||||
# pair with the core packages (Sanctum, spatie/laravel-permission,
|
# (the full RBAC/2FA/MQTT/Users-module app) and always uses SQLite — neither
|
||||||
# google2fa-laravel, php-mqtt/laravel-client) installed — the foundation to
|
# is prompted for.
|
||||||
# build on, not the full RBAC/2FA/Users-module app. Point GIT_REPO at this
|
|
||||||
# repo (https://git.innovator.bh/ghassan/webappAIO.git) to get that app.
|
|
||||||
#
|
#
|
||||||
# Supported env vars (all optional, will be prompted for if unset and a tty is available):
|
# Supported env vars (all optional, will be prompted for if unset and a tty is available):
|
||||||
# APP_NAME Project directory name (default: laravel-app)
|
# APP_NAME Project directory name (default: laravel-app)
|
||||||
# GIT_REPO Git URL to clone, blank = new project (default: "")
|
|
||||||
# DB_TYPE mysql | sqlite (default: sqlite)
|
|
||||||
# DB_USER MariaDB user (only used if DB_TYPE=mysql)
|
|
||||||
# DB_PASS MariaDB password (only used if DB_TYPE=mysql)
|
|
||||||
# DB_NAME MariaDB database name (only used if DB_TYPE=mysql)
|
|
||||||
# SUPER_ADMIN_EMAIL Seeded Super Admin email (default: [email protected])
|
# SUPER_ADMIN_EMAIL Seeded Super Admin email (default: [email protected])
|
||||||
# SUPER_ADMIN_USERNAME Seeded Super Admin username (default: superadmin)
|
# SUPER_ADMIN_USERNAME Seeded Super Admin username (default: superadmin)
|
||||||
# SUPER_ADMIN_PASSWORD Seeded Super Admin password (default: random)
|
# SUPER_ADMIN_PASSWORD Seeded Super Admin password (default: random)
|
||||||
@@ -49,7 +42,7 @@ prompt() {
|
|||||||
local var_name="$1" prompt_text="$2" default_value="${3:-}" silent="${4:-no}"
|
local var_name="$1" prompt_text="$2" default_value="${3:-}" silent="${4:-no}"
|
||||||
|
|
||||||
# A var counts as "provided" if it's set at all, even to an intentionally
|
# A var counts as "provided" if it's set at all, even to an intentionally
|
||||||
# blank value (e.g. GIT_REPO="" means "scaffold a new project").
|
# blank value.
|
||||||
if declare -p "$var_name" &>/dev/null; then
|
if declare -p "$var_name" &>/dev/null; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -84,19 +77,11 @@ fi
|
|||||||
log "webappAIO LXC Bootstrap"
|
log "webappAIO LXC Bootstrap"
|
||||||
|
|
||||||
prompt APP_NAME "Project name (directory name) [laravel-app]: " "laravel-app"
|
prompt APP_NAME "Project name (directory name) [laravel-app]: " "laravel-app"
|
||||||
prompt GIT_REPO "Git repository URL to clone (blank = new project): " ""
|
|
||||||
prompt DB_TYPE "Database type, mysql or sqlite [sqlite]: " "sqlite"
|
|
||||||
|
|
||||||
if [[ "$DB_TYPE" != "mysql" && "$DB_TYPE" != "sqlite" ]]; then
|
# Fixed, not prompted for: this script always deploys the full webappAIO app
|
||||||
warn "Unrecognized DB_TYPE '$DB_TYPE', defaulting to sqlite."
|
# against SQLite.
|
||||||
DB_TYPE="sqlite"
|
GIT_REPO="https://git.innovator.bh/ghassan/webappAIO.git"
|
||||||
fi
|
DB_TYPE="sqlite"
|
||||||
|
|
||||||
if [ "$DB_TYPE" = "mysql" ]; then
|
|
||||||
prompt DB_USER "MariaDB username [laravel_user]: " "laravel_user"
|
|
||||||
prompt DB_PASS "MariaDB password: " "$(openssl rand -hex 12)" "yes"
|
|
||||||
prompt DB_NAME "MariaDB database name [laravel_db]: " "laravel_db"
|
|
||||||
fi
|
|
||||||
|
|
||||||
prompt SUPER_ADMIN_EMAIL "Super Admin email [[email protected]]: " "[email protected]"
|
prompt SUPER_ADMIN_EMAIL "Super Admin email [[email protected]]: " "[email protected]"
|
||||||
prompt SUPER_ADMIN_USERNAME "Super Admin username [superadmin]: " "superadmin"
|
prompt SUPER_ADMIN_USERNAME "Super Admin username [superadmin]: " "superadmin"
|
||||||
@@ -160,13 +145,8 @@ systemctl start docker
|
|||||||
log "Installing Apache, PHP and common extensions"
|
log "Installing Apache, PHP and common extensions"
|
||||||
apt-get install -y apache2 php php-curl php-bcmath php-json php-mbstring php-xml php-tokenizer php-zip php-gd
|
apt-get install -y apache2 php php-curl php-bcmath php-json php-mbstring php-xml php-tokenizer php-zip php-gd
|
||||||
|
|
||||||
if [ "$DB_TYPE" = "mysql" ]; then
|
log "Installing PHP SQLite extension"
|
||||||
log "Installing MariaDB and PHP MySQL extension"
|
apt-get install -y php-sqlite3
|
||||||
apt-get install -y mariadb-server php-mysql
|
|
||||||
else
|
|
||||||
log "Installing PHP SQLite extension"
|
|
||||||
apt-get install -y php-sqlite3
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Installing Composer"
|
log "Installing Composer"
|
||||||
curl -sS https://getcomposer.org/installer | php
|
curl -sS https://getcomposer.org/installer | php
|
||||||
@@ -176,64 +156,36 @@ log "Installing Node.js LTS"
|
|||||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||||
apt-get install -y nodejs
|
apt-get install -y nodejs
|
||||||
|
|
||||||
if [ "$DB_TYPE" = "mysql" ]; then
|
|
||||||
log "Creating MariaDB database and user"
|
|
||||||
mysql -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\`;"
|
|
||||||
mysql -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';"
|
|
||||||
mysql -e "GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_USER'@'localhost';"
|
|
||||||
mysql -e "FLUSH PRIVILEGES;"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p /var/www
|
mkdir -p /var/www
|
||||||
cd /var/www
|
cd /var/www
|
||||||
|
|
||||||
if [ -z "$GIT_REPO" ]; then
|
log "Cloning $GIT_REPO into $APP_NAME"
|
||||||
log "Scaffolding new project: $APP_NAME (backend/ + frontend/)"
|
git clone "$GIT_REPO" "$APP_NAME"
|
||||||
mkdir -p "$APP_NAME"
|
cd "$APP_NAME/backend"
|
||||||
composer create-project --prefer-dist laravel/laravel "$APP_NAME/backend"
|
composer install
|
||||||
|
cd ../frontend
|
||||||
cd "$APP_NAME/backend"
|
npm install
|
||||||
log "Installing core backend packages (Sanctum, RBAC, 2FA, MQTT)"
|
log "Building the frontend for production"
|
||||||
php artisan install:api --without-migration-prompt
|
npm run build
|
||||||
composer require spatie/laravel-permission pragmarx/google2fa-laravel bacon/bacon-qr-code php-mqtt/laravel-client
|
cd /var/www/"$APP_NAME"
|
||||||
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
|
|
||||||
php artisan vendor:publish --provider="PhpMqtt\Client\MqttClientServiceProvider"
|
|
||||||
php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"
|
|
||||||
cd /var/www/"$APP_NAME"
|
|
||||||
|
|
||||||
npx --yes create-vite@latest frontend -- --template react-ts
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm install -D tailwindcss @tailwindcss/vite
|
|
||||||
npm install react-router-dom @tanstack/react-query axios mqtt qrcode
|
|
||||||
cd /var/www/"$APP_NAME"
|
|
||||||
else
|
|
||||||
log "Cloning $GIT_REPO into $APP_NAME"
|
|
||||||
git clone "$GIT_REPO" "$APP_NAME"
|
|
||||||
cd "$APP_NAME/backend"
|
|
||||||
composer install
|
|
||||||
cd ../frontend
|
|
||||||
npm install
|
|
||||||
cd /var/www/"$APP_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "/var/www/$APP_NAME/backend"
|
cd "/var/www/$APP_NAME/backend"
|
||||||
|
|
||||||
if [ "$DB_TYPE" = "sqlite" ]; then
|
log "Creating SQLite database file"
|
||||||
log "Creating SQLite database file"
|
mkdir -p database
|
||||||
mkdir -p database
|
touch database/database.sqlite
|
||||||
touch database/database.sqlite
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Setting file permissions"
|
log "Setting file permissions"
|
||||||
chown -R www-data:www-data "/var/www/$APP_NAME"
|
chown -R www-data:www-data "/var/www/$APP_NAME"
|
||||||
chmod -R 775 storage bootstrap/cache
|
chmod -R 775 storage bootstrap/cache
|
||||||
|
|
||||||
log "Configuring Apache virtual host"
|
log "Configuring Apache virtual hosts (frontend on :80, API proxied internally on 127.0.0.1:8001)"
|
||||||
a2enmod rewrite
|
a2enmod rewrite proxy proxy_http
|
||||||
|
|
||||||
cat > "/etc/apache2/sites-available/$APP_NAME.conf" <<EOF
|
cat > "/etc/apache2/sites-available/$APP_NAME-api.conf" <<EOF
|
||||||
<VirtualHost *:80>
|
Listen 127.0.0.1:8001
|
||||||
|
|
||||||
|
<VirtualHost 127.0.0.1:8001>
|
||||||
ServerAdmin webmaster@localhost
|
ServerAdmin webmaster@localhost
|
||||||
DocumentRoot /var/www/$APP_NAME/backend/public
|
DocumentRoot /var/www/$APP_NAME/backend/public
|
||||||
|
|
||||||
@@ -242,12 +194,40 @@ cat > "/etc/apache2/sites-available/$APP_NAME.conf" <<EOF
|
|||||||
Require all granted
|
Require all granted
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
|
ErrorLog \${APACHE_LOG_DIR}/$APP_NAME-api-error.log
|
||||||
|
CustomLog \${APACHE_LOG_DIR}/$APP_NAME-api-access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "/etc/apache2/sites-available/$APP_NAME.conf" <<EOF
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerAdmin webmaster@localhost
|
||||||
|
DocumentRoot /var/www/$APP_NAME/frontend/dist
|
||||||
|
|
||||||
|
<Directory /var/www/$APP_NAME/frontend/dist>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . /index.html [L]
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass /api http://127.0.0.1:8001/api
|
||||||
|
ProxyPassReverse /api http://127.0.0.1:8001/api
|
||||||
|
ProxyPass /sanctum http://127.0.0.1:8001/sanctum
|
||||||
|
ProxyPassReverse /sanctum http://127.0.0.1:8001/sanctum
|
||||||
|
|
||||||
ErrorLog \${APACHE_LOG_DIR}/$APP_NAME-error.log
|
ErrorLog \${APACHE_LOG_DIR}/$APP_NAME-error.log
|
||||||
CustomLog \${APACHE_LOG_DIR}/$APP_NAME-access.log combined
|
CustomLog \${APACHE_LOG_DIR}/$APP_NAME-access.log combined
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
a2dissite 000-default.conf || true
|
a2dissite 000-default.conf || true
|
||||||
|
a2ensite "$APP_NAME-api.conf"
|
||||||
a2ensite "$APP_NAME.conf"
|
a2ensite "$APP_NAME.conf"
|
||||||
systemctl reload apache2
|
systemctl reload apache2
|
||||||
systemctl restart apache2
|
systemctl restart apache2
|
||||||
@@ -258,16 +238,10 @@ if [ ! -f .env ] && [ -f .env.example ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
sed -i "s/^DB_CONNECTION=.*/DB_CONNECTION=$DB_TYPE/" .env
|
sed -i "s/^DB_CONNECTION=.*/DB_CONNECTION=sqlite/" .env
|
||||||
if [ "$DB_TYPE" = "mysql" ]; then
|
|
||||||
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB_NAME/" .env
|
|
||||||
sed -i "s/^DB_USERNAME=.*/DB_USERNAME=$DB_USER/" .env
|
|
||||||
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=$DB_PASS/" .env
|
|
||||||
else
|
|
||||||
sed -i "s|^DB_DATABASE=.*|DB_DATABASE=$(pwd)/database/database.sqlite|" .env
|
sed -i "s|^DB_DATABASE=.*|DB_DATABASE=$(pwd)/database/database.sqlite|" .env
|
||||||
sed -i "s/^DB_USERNAME=.*/DB_USERNAME=/" .env
|
sed -i "s/^DB_USERNAME=.*/DB_USERNAME=/" .env
|
||||||
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=/" .env
|
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=/" .env
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -383,8 +357,8 @@ SUPER_ADMIN_EMAIL=$SUPER_ADMIN_EMAIL
|
|||||||
SUPER_ADMIN_USERNAME=$SUPER_ADMIN_USERNAME
|
SUPER_ADMIN_USERNAME=$SUPER_ADMIN_USERNAME
|
||||||
SUPER_ADMIN_PASSWORD=$SUPER_ADMIN_PASSWORD
|
SUPER_ADMIN_PASSWORD=$SUPER_ADMIN_PASSWORD
|
||||||
|
|
||||||
FRONTEND_URL=http://${IP_ADDR:-localhost}:5173
|
FRONTEND_URL=http://${IP_ADDR:-localhost}
|
||||||
SANCTUM_STATEFUL_DOMAINS=localhost:5173,localhost,127.0.0.1
|
SANCTUM_STATEFUL_DOMAINS=${IP_ADDR:-localhost},localhost,127.0.0.1,localhost:5173
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
log "Generating application key, running migrations and seeders"
|
log "Generating application key, running migrations and seeders"
|
||||||
@@ -440,13 +414,14 @@ cat <<SUMMARY
|
|||||||
Project: $APP_NAME
|
Project: $APP_NAME
|
||||||
Location: /var/www/$APP_NAME (backend/, frontend/)
|
Location: /var/www/$APP_NAME (backend/, frontend/)
|
||||||
Database: $DB_TYPE
|
Database: $DB_TYPE
|
||||||
API URL: http://${IP_ADDR:-<container-ip>}/
|
Web URL: http://${IP_ADDR:-<container-ip>}/ (login page — served by Apache; API is proxied internally, nothing extra to run)
|
||||||
MQTT broker: mqtt://${IP_ADDR:-<container-ip>}:1883, ws://${IP_ADDR:-<container-ip>}:9001
|
MQTT broker: mqtt://${IP_ADDR:-<container-ip>}:1883, ws://${IP_ADDR:-<container-ip>}:9001
|
||||||
|
|
||||||
Super Admin: $SUPER_ADMIN_USERNAME / $SUPER_ADMIN_PASSWORD
|
Super Admin: $SUPER_ADMIN_USERNAME / $SUPER_ADMIN_PASSWORD
|
||||||
|
|
||||||
Frontend dev server: run 'cd /var/www/$APP_NAME/frontend && npm run dev -- --host' then tunnel port 5173.
|
|
||||||
Queue worker: running as systemd unit '$APP_NAME-queue' (needed for MQTT event publishing).
|
Queue worker: running as systemd unit '$APP_NAME-queue' (needed for MQTT event publishing).
|
||||||
|
Active development: 'cd /var/www/$APP_NAME/frontend && npm run dev -- --host' gives hot-reload on :5173;
|
||||||
|
rerun 'npm run build' there and it's picked up at the Web URL above (no restart needed).
|
||||||
|
|
||||||
$( [ "$INSTALL_CODE_SERVER" = "yes" ] && echo "code-server: run 'code-server /var/www/$APP_NAME' then tunnel port 8080 over SSH" )
|
$( [ "$INSTALL_CODE_SERVER" = "yes" ] && echo "code-server: run 'code-server /var/www/$APP_NAME' then tunnel port 8080 over SSH" )
|
||||||
$( [ "$INSTALL_CLAUDE_CODE" = "yes" ] && echo "Claude Code: run 'claude' inside /var/www/$APP_NAME to start" )
|
$( [ "$INSTALL_CLAUDE_CODE" = "yes" ] && echo "Claude Code: run 'claude' inside /var/www/$APP_NAME to start" )
|
||||||
|
|||||||
Generated
+124
@@ -8,8 +8,15 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fullcalendar/core": "^6.1.21",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.21",
|
||||||
|
"@fullcalendar/interaction": "^6.1.21",
|
||||||
|
"@fullcalendar/list": "^6.1.21",
|
||||||
|
"@fullcalendar/react": "^6.1.21",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
"admin-lte": "^4.1.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"bootstrap-icons": "^1.13.1",
|
||||||
"mqtt": "^5.15.2",
|
"mqtt": "^5.15.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@@ -38,6 +45,54 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fullcalendar/core": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "~10.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/daygrid": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/interaction": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/list": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-2rpIhs5pJmV7jyk4oX4bckNqurt6iHcsweE3FDYDdNpmRukPrARnyQYcaVFNVw2bnBFeR/jQW/St2MlauxF3GQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/react": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21",
|
||||||
|
"react": "^16.7.0 || ^17 || ^18 || ^19",
|
||||||
|
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -421,6 +476,17 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
|
||||||
@@ -1321,6 +1387,18 @@
|
|||||||
"node": ">=6.5"
|
"node": ">=6.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/admin-lte": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/admin-lte/-/admin-lte-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-XsBJSYMYjUD0PYgTeI2/3WIeYGPtr2tUAsd7ei8Zgh5bYhGzLkTxAy7aIV/k19zifmHY9llaEZ0dPIUOKCzArA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bootstrap": "^5.3.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -1407,6 +1485,42 @@
|
|||||||
"readable-stream": "^4.2.0"
|
"readable-stream": "^4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bootstrap": {
|
||||||
|
"version": "5.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||||
|
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/twbs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/bootstrap"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bootstrap-icons": {
|
||||||
|
"version": "1.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
|
||||||
|
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/twbs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/bootstrap"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/broker-factory": {
|
"node_modules/broker-factory": {
|
||||||
"version": "3.1.15",
|
"version": "3.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
|
||||||
@@ -2565,6 +2679,16 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
|
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process": {
|
"node_modules/process": {
|
||||||
"version": "0.11.10",
|
"version": "0.11.10",
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
|||||||
@@ -10,8 +10,15 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fullcalendar/core": "^6.1.21",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.21",
|
||||||
|
"@fullcalendar/interaction": "^6.1.21",
|
||||||
|
"@fullcalendar/list": "^6.1.21",
|
||||||
|
"@fullcalendar/react": "^6.1.21",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
"admin-lte": "^4.1.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"bootstrap-icons": "^1.13.1",
|
||||||
"mqtt": "^5.15.2",
|
"mqtt": "^5.15.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|||||||
@@ -14,20 +14,14 @@ export function ActivityTimeline({ items }: ActivityTimelineProps) {
|
|||||||
return (
|
return (
|
||||||
<Card title="Recent activity">
|
<Card title="Recent activity">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-500">No activity yet.</p>
|
<p className="text-secondary mb-0">No activity yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="list-group list-group-flush">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<li key={i} className="flex items-start gap-3 text-sm">
|
<li key={i} className="list-group-item px-0">
|
||||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500" />
|
<i className="bi bi-dot text-primary fs-4 align-middle"></i>
|
||||||
<div>
|
<span className="fw-semibold">{item.actor_name}</span> — {item.action.replace('.', ' ')}
|
||||||
<p className="text-gray-800 dark:text-gray-200">
|
<div className="text-secondary fs-7">{new Date(item.created_at).toLocaleString()}</div>
|
||||||
<span className="font-medium">{item.actor_name}</span> — {item.action.replace('.', ' ')}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
{new Date(item.created_at).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ interface CardProps {
|
|||||||
|
|
||||||
export function Card({ title, children }: CardProps) {
|
export function Card({ title, children }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
<div className="card">
|
||||||
{title && <h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h2>}
|
{title && (
|
||||||
{children}
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">{title}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="card-body">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ interface ChartPanelProps {
|
|||||||
data: Record<string, number>
|
data: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAR_COLOR = 'bg-blue-500'
|
const BAR_COLORS = ['bg-primary', 'bg-success', 'bg-warning', 'bg-info', 'bg-danger']
|
||||||
|
|
||||||
export function ChartPanel({ title, data }: ChartPanelProps) {
|
export function ChartPanel({ title, data }: ChartPanelProps) {
|
||||||
const entries = Object.entries(data)
|
const entries = Object.entries(data)
|
||||||
@@ -13,17 +13,20 @@ export function ChartPanel({ title, data }: ChartPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={title}>
|
<Card title={title}>
|
||||||
<div className="flex flex-col gap-2">
|
{entries.map(([label, value], i) => (
|
||||||
{entries.map(([label, value]) => (
|
<div key={label} className="mb-3">
|
||||||
<div key={label} className="flex items-center gap-3">
|
<div className="d-flex justify-content-between mb-1">
|
||||||
<span className="w-20 shrink-0 text-xs capitalize text-gray-600 dark:text-gray-400">{label}</span>
|
<span className="text-capitalize">{label}</span>
|
||||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
<span className="text-secondary">{value}</span>
|
||||||
<div className={`h-full ${BAR_COLOR}`} style={{ width: `${(value / max) * 100}%` }} />
|
</div>
|
||||||
|
<div className="progress" role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>
|
||||||
|
<div
|
||||||
|
className={`progress-bar ${BAR_COLORS[i % BAR_COLORS.length]}`}
|
||||||
|
style={{ width: `${(value / max) * 100}%` }}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-6 shrink-0 text-right text-xs text-gray-500 dark:text-gray-500">{value}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="app-footer">
|
||||||
|
<div className="float-end d-none d-sm-inline">v1.0.0</div>
|
||||||
|
<strong>Copyright © {new Date().getFullYear()} webappAIO.</strong> All rights reserved.
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
interface InfoBoxProps {
|
interface InfoBoxProps {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
|
icon: string
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoBox({ label, value }: InfoBoxProps) {
|
export function InfoBox({ label, value, icon, color = 'text-bg-primary' }: InfoBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
<div className="info-box">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-500">{label}</p>
|
<span className={`info-box-icon shadow-sm ${color}`}>
|
||||||
<p className="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</p>
|
<i className={`bi ${icon}`}></i>
|
||||||
|
</span>
|
||||||
|
<div className="info-box-content">
|
||||||
|
<span className="info-box-text">{label}</span>
|
||||||
|
<span className="info-box-number">{value}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,132 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../../features/auth/AuthContext'
|
import { useAuth } from '../../features/auth/AuthContext'
|
||||||
|
import { useNotifications } from './useNotifications'
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const notifications = useNotifications()
|
||||||
|
|
||||||
|
function handleSearch(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (search.trim()) {
|
||||||
|
navigate(`/users?search=${encodeURIComponent(search.trim())}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
|
<nav className="app-header navbar navbar-expand bg-body">
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
|
<div className="container-fluid">
|
||||||
<div className="flex items-center gap-3">
|
<ul className="navbar-nav">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">{user?.name}</span>
|
<li className="nav-item">
|
||||||
<Link
|
<a className="nav-link" data-lte-toggle="sidebar" href="#" role="button" aria-label="Toggle sidebar">
|
||||||
to="/settings/2fa"
|
<i className="bi bi-list"></i>
|
||||||
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form className="d-none d-md-flex ms-3" role="search" onSubmit={handleSearch}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Search users…"
|
||||||
|
aria-label="Search"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul className="navbar-nav ms-auto">
|
||||||
|
<li className="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
className="nav-link"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
href="#"
|
||||||
|
aria-label={`Notifications: ${notifications.length} unread`}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-bell-fill"></i>
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<span className="navbar-badge badge text-bg-warning">{notifications.length}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
<div className="dropdown-menu dropdown-menu-lg dropdown-menu-end">
|
||||||
|
<span className="dropdown-item dropdown-header">{notifications.length} Notifications</span>
|
||||||
|
<div className="dropdown-divider"></div>
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<span className="dropdown-item text-secondary">No new notifications</span>
|
||||||
|
) : (
|
||||||
|
notifications.map((n, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<a href="#" className="dropdown-item">
|
||||||
|
<i className="bi bi-person-fill me-2"></i>
|
||||||
|
{n.message}
|
||||||
|
</a>
|
||||||
|
{i < notifications.length - 1 && <div className="dropdown-divider"></div>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
className="nav-link"
|
||||||
|
href="#"
|
||||||
|
id="bd-theme"
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<i className="bi bi-sun-fill d-none" data-lte-theme-icon="light"></i>
|
||||||
|
<i className="bi bi-moon-fill" data-lte-theme-icon="dark"></i>
|
||||||
|
<i className="bi bi-circle-half d-none" data-lte-theme-icon="auto"></i>
|
||||||
|
</a>
|
||||||
|
<ul className="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme">
|
||||||
|
<li>
|
||||||
|
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="light">
|
||||||
|
<i className="bi bi-sun-fill me-2"></i>Light
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
|
||||||
|
<i className="bi bi-moon-fill me-2"></i>Dark
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
|
||||||
|
<i className="bi bi-circle-half me-2"></i>Auto
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="nav-item dropdown user-menu">
|
||||||
|
<a href="#" className="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i className="bi bi-person-circle fs-5"></i>
|
||||||
|
<span className="d-none d-md-inline ms-1">{user?.name}</span>
|
||||||
|
</a>
|
||||||
|
<ul className="dropdown-menu dropdown-menu-lg dropdown-menu-end">
|
||||||
|
<li className="user-header text-bg-primary">
|
||||||
|
<i className="bi bi-person-circle" style={{ fontSize: '3rem' }}></i>
|
||||||
|
<p>
|
||||||
|
{user?.name}
|
||||||
|
<small>{user?.roles.join(', ')}</small>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li className="user-footer">
|
||||||
|
<Link to="/settings/2fa" className="btn btn-outline-secondary">
|
||||||
2FA Settings
|
2FA Settings
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button onClick={() => logout()} className="btn btn-outline-danger float-end">
|
||||||
onClick={() => logout()}
|
|
||||||
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export interface Breadcrumb {
|
||||||
|
label: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
breadcrumbs: Breadcrumb[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, breadcrumbs }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="app-content-header">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<h1 className="mb-0 fs-3">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol className="breadcrumb float-sm-end">
|
||||||
|
{breadcrumbs.map((crumb, i) => {
|
||||||
|
const isLast = i === breadcrumbs.length - 1
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={crumb.label}
|
||||||
|
className={`breadcrumb-item ${isLast ? 'active' : ''}`}
|
||||||
|
aria-current={isLast ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{!isLast && crumb.to ? <Link to={crumb.to}>{crumb.label}</Link> : crumb.label}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +1,43 @@
|
|||||||
|
import { Fragment } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { NAV_ITEMS } from '../../layouts/shell/navConfig'
|
import { NAV_GROUPS } from '../../layouts/shell/navConfig'
|
||||||
import { usePermission } from '../../hooks/usePermission'
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { can } = usePermission()
|
const { can } = usePermission()
|
||||||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden w-56 shrink-0 border-r border-gray-200 bg-white lg:block dark:border-gray-800 dark:bg-gray-900">
|
<aside className="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
|
||||||
<nav className="flex flex-col gap-1 p-3">
|
<div className="sidebar-brand">
|
||||||
|
<a href="/dashboard" className="brand-link">
|
||||||
|
<span className="brand-text fw-light">webappAIO</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-wrapper">
|
||||||
|
<nav className="mt-2" aria-label="Main navigation">
|
||||||
|
<ul className="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false">
|
||||||
|
{NAV_GROUPS.map((group) => {
|
||||||
|
const items = group.items.filter((item) => !item.permission || can(item.permission))
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={group.header}>
|
||||||
|
<li className="nav-header">{group.header}</li>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<NavLink
|
<li key={item.to} className="nav-item">
|
||||||
key={item.to}
|
<NavLink to={item.to} className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}>
|
||||||
to={item.to}
|
<i className={`nav-icon bi ${item.icon}`}></i>
|
||||||
className={({ isActive }) =>
|
<p>{item.label}</p>
|
||||||
`rounded-md px-3 py-2 text-sm font-medium ${
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface SmallBoxProps {
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
color?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmallBox({ value, label, icon, color = 'text-bg-primary', to }: SmallBoxProps) {
|
||||||
|
return (
|
||||||
|
<div className={`small-box ${color}`}>
|
||||||
|
<div className="inner">
|
||||||
|
<h3>{value}</h3>
|
||||||
|
<p>{label}</p>
|
||||||
|
</div>
|
||||||
|
<i className={`bi ${icon} small-box-icon`}></i>
|
||||||
|
{to && (
|
||||||
|
<Link to={to} className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover">
|
||||||
|
More info <i className="bi bi-arrow-right-short"></i>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useMqttTopic } from '../../hooks/useMqtt'
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersEventPayload {
|
||||||
|
type: 'UserCreated' | 'UserUpdated' | 'UserDeleted' | 'RoleAssigned'
|
||||||
|
user: { name: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<UsersEventPayload['type'], (name: string) => string> = {
|
||||||
|
UserCreated: (name) => `${name} was added.`,
|
||||||
|
UserUpdated: (name) => `${name} was updated.`,
|
||||||
|
UserDeleted: (name) => `${name} was removed.`,
|
||||||
|
RoleAssigned: (name) => `${name}'s role was changed.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_NOTIFICATIONS = 10
|
||||||
|
|
||||||
|
/** Rolling list of recent Users-module events shown in the navbar bell. */
|
||||||
|
export function useNotifications(): Notification[] {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
|
|
||||||
|
useMqttTopic('module/users/events', (payload) => {
|
||||||
|
const event = payload as UsersEventPayload
|
||||||
|
const message = MESSAGES[event.type]?.(event.user.name) ?? 'Users list updated.'
|
||||||
|
setNotifications((prev) => [{ message }, ...prev].slice(0, MAX_NOTIFICATIONS))
|
||||||
|
})
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
}
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { NAV_ITEMS } from '../../layouts/shell/navConfig'
|
import { MOBILE_NAV_ITEMS } from '../../layouts/shell/navConfig'
|
||||||
import { usePermission } from '../../hooks/usePermission'
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
const { can } = usePermission()
|
const { can } = usePermission()
|
||||||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
const items = MOBILE_NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex h-14 items-stretch border-t border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
<nav className="mob-tab-bar">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) => `mob-tab-item ${isActive ? 'mob-active' : ''}`}
|
||||||
`flex flex-1 items-center justify-center text-sm font-medium ${
|
|
||||||
isActive
|
|
||||||
? 'text-blue-700 dark:text-blue-400'
|
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
<span className={`mob-nav-icon bi ${item.icon}`}></span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { NAV_GROUPS } from '../../layouts/shell/navConfig'
|
||||||
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
|
||||||
|
interface SlideOutNavProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlideOutNav({ open, onClose }: SlideOutNavProps) {
|
||||||
|
const { can } = usePermission()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`mob-sidebar-backdrop ${open ? 'mob-show' : ''}`} onClick={onClose} />
|
||||||
|
<nav className={`mob-sidebar ${open ? 'mob-open' : ''}`} aria-label="Main navigation">
|
||||||
|
<div className="mob-sidebar-header">
|
||||||
|
<span className="mob-sidebar-title">webappAIO</span>
|
||||||
|
<button className="mob-sidebar-close" onClick={onClose} aria-label="Close menu">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{NAV_GROUPS.map((group) => {
|
||||||
|
const items = group.items.filter((item) => !item.permission || can(item.permission))
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.header}>
|
||||||
|
<div className="mob-sidebar-section-label">{group.header}</div>
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
onClick={onClose}
|
||||||
|
className={({ isActive }) => `mob-sidebar-link ${isActive ? 'mob-active' : ''}`}
|
||||||
|
>
|
||||||
|
<span className={`mob-nav-icon bi ${item.icon}`}></span>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,34 @@
|
|||||||
import { useAuth } from '../../features/auth/AuthContext'
|
import { useAuth } from '../../features/auth/AuthContext'
|
||||||
|
import { useNotifications } from '../desktop/useNotifications'
|
||||||
|
|
||||||
export function TopBar() {
|
interface TopBarProps {
|
||||||
|
onMenuClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ onMenuClick }: TopBarProps) {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
|
const notifications = useNotifications()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
|
<header className="mob-header">
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
|
<div className="mob-header-left">
|
||||||
<button
|
<button className="mob-menu-btn" onClick={onMenuClick} aria-label="Open menu">
|
||||||
onClick={() => logout()}
|
<i className="bi bi-list"></i>
|
||||||
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
</button>
|
||||||
|
<div className="mob-title">
|
||||||
|
<span>webappAIO</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mob-header-actions">
|
||||||
|
<button className="mob-icon-btn" aria-label={`Notifications: ${notifications.length} unread`}>
|
||||||
|
<i className="bi bi-bell-fill"></i>
|
||||||
|
{notifications.length > 0 && <span className="mob-badge-dot"></span>}
|
||||||
|
</button>
|
||||||
|
<button className="mob-icon-btn" onClick={() => logout()} aria-label="Sign out">
|
||||||
|
<i className="bi bi-box-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { Card } from '../desktop/Card'
|
||||||
|
|
||||||
export interface DataTableColumn<T> {
|
export interface DataTableColumn<T> {
|
||||||
key: string
|
key: string
|
||||||
@@ -32,21 +33,22 @@ export function DataTable<T>({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
|
const pages = Array.from({ length: lastPage }, (_, i) => i + 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
<Card>
|
||||||
<div className="overflow-x-auto">
|
<div className="table-responsive">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="table table-striped table-hover align-middle mb-0">
|
||||||
<thead className="border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-800/50">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th key={col.key} className="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">
|
<th key={col.key}>
|
||||||
{col.sortable ? (
|
{col.sortable ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => onSortChange?.(col.key)}
|
onClick={() => onSortChange?.(col.key)}
|
||||||
className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
|
className="btn btn-link p-0 text-decoration-none fw-semibold"
|
||||||
>
|
>
|
||||||
{col.label}
|
{col.label} {sort === col.key && <i className={`bi bi-caret-${direction === 'asc' ? 'up' : 'down'}-fill`}></i>}
|
||||||
{sort === col.key && <span>{direction === 'asc' ? '↑' : '↓'}</span>}
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
col.label
|
col.label
|
||||||
@@ -58,23 +60,21 @@ export function DataTable<T>({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
|
<td colSpan={columns.length} className="text-center text-secondary py-4">
|
||||||
Loading…
|
Loading…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
|
<td colSpan={columns.length} className="text-center text-secondary py-4">
|
||||||
No results.
|
No results.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
rows.map((row) => (
|
||||||
<tr key={rowKey(row)} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
|
<tr key={rowKey(row)}>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td key={col.key} className="px-4 py-2 text-gray-800 dark:text-gray-200">
|
<td key={col.key}>{col.render(row)}</td>
|
||||||
{col.render(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -84,26 +84,28 @@ export function DataTable<T>({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lastPage > 1 && (
|
{lastPage > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-sm dark:border-gray-800">
|
<nav className="mt-3" aria-label="Table pagination">
|
||||||
<button
|
<ul className="pagination pagination-sm justify-content-end mb-0">
|
||||||
disabled={page <= 1}
|
<li className={`page-item ${page <= 1 ? 'disabled' : ''}`}>
|
||||||
onClick={() => onPageChange(page - 1)}
|
<button className="page-link" onClick={() => onPageChange(page - 1)}>
|
||||||
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-500 dark:text-gray-500">
|
</li>
|
||||||
Page {page} of {lastPage}
|
{pages.map((p) => (
|
||||||
</span>
|
<li key={p} className={`page-item ${p === page ? 'active' : ''}`}>
|
||||||
<button
|
<button className="page-link" onClick={() => onPageChange(p)}>
|
||||||
disabled={page >= lastPage}
|
{p}
|
||||||
onClick={() => onPageChange(page + 1)}
|
</button>
|
||||||
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
|
</li>
|
||||||
>
|
))}
|
||||||
|
<li className={`page-item ${page >= lastPage ? 'disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => onPageChange(page + 1)}>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import FullCalendar from '@fullcalendar/react'
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
|
import interactionPlugin, { Draggable } from '@fullcalendar/interaction'
|
||||||
|
import listPlugin from '@fullcalendar/list'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
|
||||||
|
const INITIAL_EVENTS = [
|
||||||
|
{ title: 'Team standup', date: new Date().toISOString().slice(0, 10) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DRAGGABLE_EVENTS = ['Customer call', 'Design review', 'Sprint planning', 'Deploy release']
|
||||||
|
|
||||||
|
export function CalendarPage() {
|
||||||
|
const draggableRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [removeAfterDrop, setRemoveAfterDrop] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggableRef.current) return
|
||||||
|
const draggable = new Draggable(draggableRef.current, {
|
||||||
|
itemSelector: '.draggable-event',
|
||||||
|
eventData: (el) => ({ title: el.innerText }),
|
||||||
|
})
|
||||||
|
return () => draggable.destroy()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Calendar" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Calendar' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<Card title="Draggable events">
|
||||||
|
<div ref={draggableRef}>
|
||||||
|
{DRAGGABLE_EVENTS.map((title) => (
|
||||||
|
<div key={title} className="draggable-event fc-event p-2 mb-2 bg-primary text-white rounded">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="form-check mt-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={removeAfterDrop}
|
||||||
|
onChange={(e) => setRemoveAfterDrop(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label">Remove after drop</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">
|
||||||
|
Static demo — dropped events aren't persisted to a backend.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-9">
|
||||||
|
<Card>
|
||||||
|
<FullCalendar
|
||||||
|
plugins={[dayGridPlugin, interactionPlugin, listPlugin]}
|
||||||
|
initialView="dayGridMonth"
|
||||||
|
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,listWeek' }}
|
||||||
|
droppable
|
||||||
|
editable
|
||||||
|
events={INITIAL_EVENTS}
|
||||||
|
eventReceive={(info) => {
|
||||||
|
if (removeAfterDrop) {
|
||||||
|
info.draggedEl.parentElement?.removeChild(info.draggedEl)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
preview: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
from: 'me' | 'them'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTACTS: Contact[] = [
|
||||||
|
{ id: 1, name: 'Alice Manager', preview: 'Sounds good, thanks!', time: '2:30 PM' },
|
||||||
|
{ id: 2, name: 'Bob Admin', preview: 'Can you review the PR?', time: '11:05 AM' },
|
||||||
|
{ id: 3, name: 'Carol User', preview: 'See you tomorrow', time: 'Yesterday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MESSAGES: Record<number, Message[]> = {
|
||||||
|
1: [
|
||||||
|
{ from: 'them', text: 'Hey, got a minute?' },
|
||||||
|
{ from: 'me', text: 'Sure, what\'s up?' },
|
||||||
|
{ from: 'them', text: 'Sounds good, thanks!' },
|
||||||
|
],
|
||||||
|
2: [{ from: 'them', text: 'Can you review the PR?' }],
|
||||||
|
3: [{ from: 'them', text: 'See you tomorrow' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
const [activeId, setActiveId] = useState(1)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const active = CONTACTS.find((c) => c.id === activeId)!
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Chat" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Chat' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="card">
|
||||||
|
<div className="row g-0" style={{ minHeight: '480px' }}>
|
||||||
|
<div className="col-md-4 border-end">
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{CONTACTS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setActiveId(c.id)}
|
||||||
|
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-start ${
|
||||||
|
c.id === activeId ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="fw-semibold">{c.name}</div>
|
||||||
|
<div className="fs-7 text-truncate" style={{ maxWidth: '160px' }}>
|
||||||
|
{c.preview}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="fs-7 text-secondary">{c.time}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-8 d-flex flex-column">
|
||||||
|
<div className="p-3 border-bottom fw-semibold">{active.name}</div>
|
||||||
|
<div className="flex-grow-1 p-3" style={{ overflowY: 'auto' }}>
|
||||||
|
{(MESSAGES[activeId] ?? []).map((m, i) => (
|
||||||
|
<div key={i} className={`d-flex mb-2 ${m.from === 'me' ? 'justify-content-end' : ''}`}>
|
||||||
|
<span className={`px-3 py-2 rounded-3 ${m.from === 'me' ? 'bg-primary text-white' : 'bg-body-secondary'}`}>
|
||||||
|
{m.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border-top d-flex gap-2">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Type a message… (not sent anywhere)"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" disabled>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">Static demo — no messaging backend exists yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
import { useMqttTopic } from '../../hooks/useMqtt'
|
||||||
|
import { fetchStats, fetchActivity } from './dashboardApi'
|
||||||
|
|
||||||
|
export function DashboardPageMobile() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { can } = usePermission()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({ queryKey: ['dashboard', 'stats'], queryFn: fetchStats })
|
||||||
|
const { data: activity } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'activity'],
|
||||||
|
queryFn: fetchActivity,
|
||||||
|
enabled: can('audit.view'),
|
||||||
|
})
|
||||||
|
|
||||||
|
useMqttTopic('module/users/events', () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleEntries = Object.entries(stats?.users_by_role ?? {})
|
||||||
|
const maxRoleCount = Math.max(1, ...roleEntries.map(([, v]) => v))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: 'var(--mob-text-medium)', marginBottom: 14 }}>Welcome, {user?.name}.</p>
|
||||||
|
|
||||||
|
<div className="mob-kpi-grid">
|
||||||
|
<div className="mob-kpi-card">
|
||||||
|
<div className="mob-kpi-label">Total Users</div>
|
||||||
|
<div className="mob-kpi-value">{stats?.total_users ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mob-section-card">
|
||||||
|
<div className="mob-section-header">
|
||||||
|
<h4>Users by role</h4>
|
||||||
|
</div>
|
||||||
|
{roleEntries.map(([label, value]) => (
|
||||||
|
<div key={label} style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||||
|
<span style={{ textTransform: 'capitalize' }}>{label}</span>
|
||||||
|
<span style={{ color: 'var(--mob-text-light)' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 7,
|
||||||
|
background: 'var(--mob-border)',
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${(value / maxRoleCount) * 100}%`,
|
||||||
|
background: 'linear-gradient(90deg, var(--mob-primary), var(--mob-primary-light))',
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{can('audit.view') && (
|
||||||
|
<div className="mob-section-card">
|
||||||
|
<div className="mob-section-header">
|
||||||
|
<h4>Recent activity</h4>
|
||||||
|
</div>
|
||||||
|
{(activity ?? []).length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--mob-text-light)', margin: 0 }}>No activity yet.</p>
|
||||||
|
) : (
|
||||||
|
(activity ?? []).map((item, i) => (
|
||||||
|
<div key={i} className="mob-list-item">
|
||||||
|
<span className="mob-avatar">{item.actor_name.slice(0, 2).toUpperCase()}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{item.actor_name} — {item.action.replace('.', ' ')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--mob-text-light)' }}>
|
||||||
|
{new Date(item.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { usePermission } from '../../hooks/usePermission'
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
import { useMqttTopic } from '../../hooks/useMqtt'
|
import { useMqttTopic } from '../../hooks/useMqtt'
|
||||||
import { InfoBox } from '../../components/desktop/InfoBox'
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { SmallBox } from '../../components/desktop/SmallBox'
|
||||||
import { ChartPanel } from '../../components/desktop/ChartPanel'
|
import { ChartPanel } from '../../components/desktop/ChartPanel'
|
||||||
import { ActivityTimeline } from '../../components/desktop/ActivityTimeline'
|
import { ActivityTimeline } from '../../components/desktop/ActivityTimeline'
|
||||||
import { fetchStats, fetchActivity } from './dashboardApi'
|
import { fetchStats, fetchActivity } from './dashboardApi'
|
||||||
@@ -24,20 +25,30 @@ export function DashboardPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Dashboard' }]} />
|
||||||
<p className="mt-1 mb-6 text-sm text-gray-600 dark:text-gray-400">Welcome, {user?.name}.</p>
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<p className="text-secondary">Welcome, {user?.name}.</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="row">
|
||||||
<InfoBox label="Total users" value={stats?.total_users ?? '—'} />
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={stats?.total_users ?? '—'} label="Total Users" icon="bi-people-fill" to="/users" />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 col-12">
|
||||||
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
|
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{can('audit.view') && (
|
{can('audit.view') && (
|
||||||
<div className="mt-4">
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
<ActivityTimeline items={activity ?? []} />
|
<ActivityTimeline items={activity ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useIsMobile } from '../../layouts/shell/useIsMobile'
|
||||||
|
import { DashboardPage } from './DashboardPage'
|
||||||
|
import { DashboardPageMobile } from './DashboardPage.mobile'
|
||||||
|
|
||||||
|
export function DashboardRoute() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
return isMobile ? <DashboardPageMobile /> : <DashboardPage />
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
|
||||||
|
const CATEGORIES = ['My Drive', 'Documents', 'Shared with me', 'Starred', 'Recent', 'Trash']
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
name: string
|
||||||
|
type: 'folder' | 'file'
|
||||||
|
size?: string
|
||||||
|
modified: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRIES: Entry[] = [
|
||||||
|
{ name: 'Reports', type: 'folder', modified: 'Aug 1, 2026' },
|
||||||
|
{ name: 'Contracts', type: 'folder', modified: 'Jul 28, 2026' },
|
||||||
|
{ name: 'Q3-summary.pdf', type: 'file', size: '2.4 MB', modified: 'Aug 2, 2026' },
|
||||||
|
{ name: 'logo.png', type: 'file', size: '340 KB', modified: 'Jul 20, 2026' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function FileManagerPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="File Manager" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'File Manager' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<button className="btn btn-primary w-100 mb-2" disabled>
|
||||||
|
<i className="bi bi-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary w-100 mb-3" disabled>
|
||||||
|
<i className="bi bi-folder-plus me-1"></i>New folder
|
||||||
|
</button>
|
||||||
|
<ul className="list-group list-group-flush">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<li key={c} className="list-group-item">
|
||||||
|
<i className="bi bi-folder me-2"></i>
|
||||||
|
{c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="fs-7 text-secondary mb-1">6.2 GB of 15 GB used</div>
|
||||||
|
<div className="progress">
|
||||||
|
<div className="progress-bar" style={{ width: '41%' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-9">
|
||||||
|
<nav aria-label="breadcrumb" className="mb-2">
|
||||||
|
<ol className="breadcrumb">
|
||||||
|
<li className="breadcrumb-item">My Drive</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Modified</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ENTRIES.map((entry) => (
|
||||||
|
<tr key={entry.name}>
|
||||||
|
<td>
|
||||||
|
<i className={`bi ${entry.type === 'folder' ? 'bi-folder-fill text-warning' : 'bi-file-earmark'} me-2`}></i>
|
||||||
|
{entry.name}
|
||||||
|
</td>
|
||||||
|
<td>{entry.size ?? '—'}</td>
|
||||||
|
<td>{entry.modified}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">Static demo — no file storage backend exists yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
|
||||||
|
const LINE_ITEMS = [
|
||||||
|
{ description: 'Premium support plan', qty: 1, unitPrice: 200, amount: 200 },
|
||||||
|
{ description: 'Extra seats (5)', qty: 5, unitPrice: 20, amount: 100 },
|
||||||
|
{ description: 'Onboarding session', qty: 1, unitPrice: 50, amount: 50 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function InvoicePage() {
|
||||||
|
const subtotal = LINE_ITEMS.reduce((sum, item) => sum + item.amount, 0)
|
||||||
|
const tax = subtotal * 0.1
|
||||||
|
const total = subtotal + tax
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Invoice" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Invoice' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<Card>
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-6">
|
||||||
|
<h4>webappAIO Inc.</h4>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
1234 Example Street
|
||||||
|
<br />
|
||||||
|
Manama, Bahrain
|
||||||
|
<br />
|
||||||
|
hello@webappaio.example
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 text-end">
|
||||||
|
<h4>Invoice #00123</h4>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Bill to: Jane Doe
|
||||||
|
<br />
|
||||||
|
Date: {new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th className="text-end">Qty</th>
|
||||||
|
<th className="text-end">Unit Price</th>
|
||||||
|
<th className="text-end">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{LINE_ITEMS.map((item) => (
|
||||||
|
<tr key={item.description}>
|
||||||
|
<td>{item.description}</td>
|
||||||
|
<td className="text-end">{item.qty}</td>
|
||||||
|
<td className="text-end">${item.unitPrice.toFixed(2)}</td>
|
||||||
|
<td className="text-end">${item.amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-4 offset-md-8">
|
||||||
|
<table className="table mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Subtotal</th>
|
||||||
|
<td className="text-end">${subtotal.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Tax (10%)</th>
|
||||||
|
<td className="text-end">${tax.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total</th>
|
||||||
|
<td className="text-end fw-bold">${total.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<button className="btn btn-primary me-2" disabled>
|
||||||
|
<i className="bi bi-printer me-1"></i>Print
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary me-2" disabled>
|
||||||
|
<i className="bi bi-file-earmark-pdf me-1"></i>Download PDF
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary" disabled>
|
||||||
|
<i className="bi bi-send me-1"></i>Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">
|
||||||
|
Static template — no invoicing/billing backend exists yet.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
|
||||||
|
type Tab = 'activity' | 'timeline' | 'about'
|
||||||
|
|
||||||
|
const ACTIVITY = [
|
||||||
|
{ text: 'Signed in to the dashboard', time: 'Just now' },
|
||||||
|
{ text: 'Updated a user record', time: '2 hours ago' },
|
||||||
|
{ text: 'Assigned a new role', time: 'Yesterday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [tab, setTab] = useState<Tab>('activity')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Profile" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Profile' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<Card>
|
||||||
|
<div className="text-center">
|
||||||
|
<i className="bi bi-person-circle" style={{ fontSize: '5rem' }}></i>
|
||||||
|
<h3 className="mt-2 mb-0">{user?.name}</h3>
|
||||||
|
<p className="text-secondary">{user?.roles.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-around text-center border-top pt-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<strong>24</strong>
|
||||||
|
<div className="text-secondary fs-7">Followers</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>13</strong>
|
||||||
|
<div className="text-secondary fs-7">Following</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>8</strong>
|
||||||
|
<div className="text-secondary fs-7">Friends</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-9">
|
||||||
|
<ul className="nav nav-tabs mb-3">
|
||||||
|
{(['activity', 'timeline', 'about'] as Tab[]).map((t) => (
|
||||||
|
<li key={t} className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link text-capitalize ${tab === t ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{tab === 'activity' && (
|
||||||
|
<Card>
|
||||||
|
<ul className="list-group list-group-flush">
|
||||||
|
{ACTIVITY.map((item, i) => (
|
||||||
|
<li key={i} className="list-group-item px-0">
|
||||||
|
{item.text}
|
||||||
|
<div className="text-secondary fs-7">{item.time}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'timeline' && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-secondary mb-0">No timeline entries yet.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'about' && (
|
||||||
|
<Card>
|
||||||
|
<p>
|
||||||
|
<strong>Education:</strong> —
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Location:</strong> —
|
||||||
|
</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
<strong>Skills:</strong> —
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { SmallBox } from '../../components/desktop/SmallBox'
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
name: string
|
||||||
|
status: 'On track' | 'At risk' | 'Completed'
|
||||||
|
progress: number
|
||||||
|
team: string
|
||||||
|
budget: string
|
||||||
|
due: string
|
||||||
|
priority: 'High' | 'Medium' | 'Low'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECTS: Project[] = [
|
||||||
|
{ name: 'Website redesign', status: 'On track', progress: 65, team: 'Alice, Bob', budget: '$12,000', due: 'Sep 1, 2026', priority: 'High' },
|
||||||
|
{ name: 'Mobile app v2', status: 'At risk', progress: 30, team: 'Carol, Dave', budget: '$25,000', due: 'Oct 15, 2026', priority: 'High' },
|
||||||
|
{ name: 'Internal tooling', status: 'Completed', progress: 100, team: 'Eve', budget: '$4,000', due: 'Jul 20, 2026', priority: 'Low' },
|
||||||
|
{ name: 'API v3 migration', status: 'On track', progress: 48, team: 'Frank, Grace', budget: '$18,000', due: 'Nov 1, 2026', priority: 'Medium' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<Project['status'], string> = {
|
||||||
|
'On track': 'text-bg-success',
|
||||||
|
'At risk': 'text-bg-danger',
|
||||||
|
Completed: 'text-bg-secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const total = PROJECTS.length
|
||||||
|
const onTrack = PROJECTS.filter((p) => p.status === 'On track').length
|
||||||
|
const atRisk = PROJECTS.filter((p) => p.status === 'At risk').length
|
||||||
|
const completed = PROJECTS.filter((p) => p.status === 'Completed').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Projects" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Projects' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={total} label="Total Projects" icon="bi-kanban" color="text-bg-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={onTrack} label="On Track" icon="bi-check-circle" color="text-bg-success" />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={atRisk} label="At Risk" icon="bi-exclamation-triangle" color="text-bg-danger" />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={completed} label="Completed" icon="bi-flag" color="text-bg-secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Team</th>
|
||||||
|
<th>Budget</th>
|
||||||
|
<th>Due</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PROJECTS.map((p) => (
|
||||||
|
<tr key={p.name}>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${STATUS_BADGE[p.status]}`}>{p.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ minWidth: '120px' }}>
|
||||||
|
<div className="progress">
|
||||||
|
<div className="progress-bar" style={{ width: `${p.progress}%` }}>
|
||||||
|
{p.progress}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{p.team}</td>
|
||||||
|
<td>{p.budget}</td>
|
||||||
|
<td>{p.due}</td>
|
||||||
|
<td>{p.priority}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">Static demo — no project-tracking backend exists yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { fetchRoleOverview } from './rolesApi'
|
||||||
|
|
||||||
|
export function RolesPage() {
|
||||||
|
const { data: roles = [], isLoading } = useQuery({ queryKey: ['roles', 'overview'], queryFn: fetchRoleOverview })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Roles" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Roles' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<p className="text-secondary">
|
||||||
|
Read-only view of each role's permissions, as seeded by <code>RolePermissionSeeder</code>. Permissions
|
||||||
|
aren't editable from here — they're fixed by the backend seed data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<div key={role.name} className="col-md-6 col-lg-3 mb-4">
|
||||||
|
<Card title={role.name}>
|
||||||
|
{role.permissions?.length ? (
|
||||||
|
<div className="d-flex flex-wrap gap-1">
|
||||||
|
{role.permissions.map((p) => (
|
||||||
|
<span key={p} className="badge text-bg-secondary">
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-0">No permissions.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { apiClient } from '../../services/apiClient'
|
||||||
|
import type { Role } from '../../types/role'
|
||||||
|
|
||||||
|
export async function fetchRoleOverview(): Promise<Role[]> {
|
||||||
|
const { data } = await apiClient.get('/roles/overview')
|
||||||
|
return data.data as Role[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { TwoFactorSetupPage } from '../auth/TwoFactorSetupPage'
|
||||||
|
|
||||||
|
type Tab = 'account' | 'notifications' | 'security' | 'billing' | 'danger'
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: 'account', label: 'Account' },
|
||||||
|
{ key: 'notifications', label: 'Notifications' },
|
||||||
|
{ key: 'security', label: 'Security' },
|
||||||
|
{ key: 'billing', label: 'Billing' },
|
||||||
|
{ key: 'danger', label: 'Danger Zone' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [tab, setTab] = useState<Tab>('account')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Settings" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Settings' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<ul className="nav nav-tabs mb-3">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<li key={t.key} className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${tab === t.key ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{tab === 'account' && (
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input type="text" defaultValue={user?.name} className="form-control" disabled />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input type="email" defaultValue={user?.email} className="form-control" disabled />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Timezone</label>
|
||||||
|
<select className="form-select" disabled>
|
||||||
|
<option>UTC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mb-0">
|
||||||
|
Account fields are managed via the Users module (or by an admin) — not editable here yet.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'notifications' && (
|
||||||
|
<Card>
|
||||||
|
{['Email notifications', 'Push notifications', 'Weekly digest', 'Security alerts'].map((label) => (
|
||||||
|
<div key={label} className="form-check form-switch mb-2">
|
||||||
|
<input className="form-check-input" type="checkbox" disabled defaultChecked />
|
||||||
|
<label className="form-check-label">{label}</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-secondary fs-7 mb-0 mt-2">Placeholder — no notification-preferences backend yet.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'security' && <TwoFactorSetupPage />}
|
||||||
|
|
||||||
|
{tab === 'billing' && (
|
||||||
|
<Card title="Plan">
|
||||||
|
<p>
|
||||||
|
Current plan: <span className="badge text-bg-primary">Self-hosted</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary fs-7 mb-0">
|
||||||
|
No billing integration — this app is self-hosted, so there's nothing to bill.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'danger' && (
|
||||||
|
<Card title="Danger zone">
|
||||||
|
<p className="text-secondary">Manage account deletion and data export here.</p>
|
||||||
|
<button className="btn btn-outline-danger" disabled>
|
||||||
|
Delete account
|
||||||
|
</button>
|
||||||
|
<p className="text-secondary fs-7 mb-0 mt-2">
|
||||||
|
Disabled placeholder — account deletion for the signed-in user isn't implemented.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { useConfirm } from '../../components/shared/ConfirmDialog'
|
||||||
|
import { useToast } from '../../components/shared/Toast/ToastProvider'
|
||||||
|
import { downloadBackup, restoreBackup } from './systemApi'
|
||||||
|
|
||||||
|
export function BackupRestorePage() {
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
const [isRestoring, setIsRestoring] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
setIsDownloading(true)
|
||||||
|
try {
|
||||||
|
await downloadBackup()
|
||||||
|
toast.success('Backup downloaded.')
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not download the backup.')
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore() {
|
||||||
|
const file = fileInputRef.current?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Restore database',
|
||||||
|
message: `This will replace the current database with "${file.name}". This cannot be undone from here. Continue?`,
|
||||||
|
confirmLabel: 'Restore',
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setIsRestoring(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await restoreBackup(file)
|
||||||
|
toast.success('Database restored. You may need to sign in again.')
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
} catch {
|
||||||
|
setError('That file could not be restored — make sure it is a valid SQLite backup.')
|
||||||
|
} finally {
|
||||||
|
setIsRestoring(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Backup & Restore"
|
||||||
|
breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Backup & Restore' }]}
|
||||||
|
/>
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Card title="Download backup">
|
||||||
|
<p className="text-secondary">Download the current SQLite database file.</p>
|
||||||
|
<button onClick={handleDownload} disabled={isDownloading} className="btn btn-primary">
|
||||||
|
<i className="bi bi-download me-1"></i>
|
||||||
|
{isDownloading ? 'Downloading…' : 'Download backup'}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Card title="Restore from backup">
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
<p className="text-secondary">
|
||||||
|
Upload a previously downloaded <code>.sqlite</code> file to replace the current database. A safety
|
||||||
|
copy of the current database is kept on the server before restoring.
|
||||||
|
</p>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".sqlite" className="form-control mb-3" />
|
||||||
|
<button onClick={handleRestore} disabled={isRestoring} className="btn btn-danger">
|
||||||
|
<i className="bi bi-upload me-1"></i>
|
||||||
|
{isRestoring ? 'Restoring…' : 'Restore'}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { fetchMqttSettings } from './systemApi'
|
||||||
|
|
||||||
|
export function MqttSettingsPage() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['mqtt', 'settings'],
|
||||||
|
queryFn: fetchMqttSettings,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="MQTT Settings"
|
||||||
|
breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'MQTT Settings' }]}
|
||||||
|
/>
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Card title="Broker connection">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table className="table table-borderless mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Status</th>
|
||||||
|
<td>
|
||||||
|
{data?.connected ? (
|
||||||
|
<span className="badge text-bg-success">
|
||||||
|
<i className="bi bi-check-circle-fill me-1"></i>Connected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge text-bg-danger">
|
||||||
|
<i className="bi bi-x-circle-fill me-1"></i>Unreachable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Host</th>
|
||||||
|
<td>{data?.host}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Port</th>
|
||||||
|
<td>{data?.port}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">WebSocket URL</th>
|
||||||
|
<td>{data?.ws_url}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">TLS</th>
|
||||||
|
<td>{data?.tls_enabled ? 'Enabled' : 'Disabled'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<p className="text-secondary fs-7">
|
||||||
|
Broker credentials aren't shown here — role-scoped tokens are issued per-session via{' '}
|
||||||
|
<code>/api/v1/mqtt/token</code>. This page reflects config only super-admins can view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { apiClient } from '../../services/apiClient'
|
||||||
|
|
||||||
|
export interface MqttSettings {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
ws_url: string
|
||||||
|
tls_enabled: boolean
|
||||||
|
connected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMqttSettings(): Promise<MqttSettings> {
|
||||||
|
const { data } = await apiClient.get('/mqtt/settings')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBackup(): Promise<void> {
|
||||||
|
const response = await apiClient.get('/backup/download', { responseType: 'blob' })
|
||||||
|
const url = URL.createObjectURL(response.data)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `backup-${new Date().toISOString().replace(/[:.]/g, '-')}.sqlite`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBackup(file: File): Promise<void> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('backup', file)
|
||||||
|
await apiClient.post('/backup/restore', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useConfirm } from '../../components/shared/ConfirmDialog'
|
import { useConfirm } from '../../components/shared/ConfirmDialog'
|
||||||
import { useToast } from '../../components/shared/Toast/ToastProvider'
|
import { useToast } from '../../components/shared/Toast/ToastProvider'
|
||||||
import { usePermission } from '../../hooks/usePermission'
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
@@ -16,15 +17,22 @@ export function UsersListPageMobile() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
useUsersRealtime()
|
useUsersRealtime()
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const search = searchParams.get('search') ?? ''
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [formUser, setFormUser] = useState<User | null | undefined>(undefined)
|
const [formUser, setFormUser] = useState<User | null | undefined>(undefined)
|
||||||
const [roleUser, setRoleUser] = useState<User | null>(null)
|
const [roleUser, setRoleUser] = useState<User | null>(null)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const }],
|
queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const, search }],
|
||||||
queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc' }),
|
queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc', search: search || undefined }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleSearchChange(value: string) {
|
||||||
|
setPage(1)
|
||||||
|
setSearchParams(value ? { search: value } : {})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(user: User) {
|
async function handleDelete(user: User) {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Delete user',
|
title: 'Delete user',
|
||||||
@@ -41,68 +49,77 @@ export function UsersListPageMobile() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mob-search-bar">
|
||||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
|
<input
|
||||||
|
type="search"
|
||||||
|
defaultValue={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="mob-search-input"
|
||||||
|
placeholder="🔍 Search users…"
|
||||||
|
/>
|
||||||
{can('users.create') && (
|
{can('users.create') && (
|
||||||
<button
|
<button onClick={() => setFormUser(null)} className="mob-pill-btn">
|
||||||
onClick={() => setFormUser(null)}
|
<span>➕</span> Add
|
||||||
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mob-section-card">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-gray-500">Loading…</p>
|
<p style={{ color: 'var(--mob-text-light)' }}>Loading…</p>
|
||||||
|
) : (data?.data ?? []).length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--mob-text-light)' }}>No results.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
(data?.data ?? []).map((u) => (
|
||||||
{(data?.data ?? []).map((u) => (
|
<div key={u.id} className="mob-list-item" style={{ alignItems: 'flex-start' }}>
|
||||||
<div
|
<span className="mob-avatar">{u.name.slice(0, 2).toUpperCase()}</span>
|
||||||
key={u.id}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
<p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>{u.name}</p>
|
||||||
>
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--mob-text-light)' }}>{u.email}</p>
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">{u.name}</p>
|
<span className="mob-status-badge mob-neutral" style={{ marginTop: 6 }}>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-500">{u.email}</p>
|
{u.roles.join(', ')}
|
||||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-600">{u.roles.join(', ')}</p>
|
</span>
|
||||||
<div className="mt-3 flex gap-4 text-sm">
|
<div style={{ marginTop: 8, display: 'flex', gap: 14 }}>
|
||||||
{u.can_edit && (
|
{u.can_edit && (
|
||||||
<button onClick={() => setFormUser(u)} className="text-blue-600 dark:text-blue-400">
|
<button onClick={() => setFormUser(u)} className="mob-text-btn">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{can('roles.assign') && (
|
{can('roles.assign') && (
|
||||||
<button onClick={() => setRoleUser(u)} className="text-blue-600 dark:text-blue-400">
|
<button onClick={() => setRoleUser(u)} className="mob-text-btn">
|
||||||
Role
|
Role
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{u.can_delete && (
|
{u.can_delete && (
|
||||||
<button onClick={() => handleDelete(u)} className="text-red-600 dark:text-red-400">
|
<button onClick={() => handleDelete(u)} className="mob-text-btn mob-danger">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{data && data.meta.last_page > 1 && (
|
{data && data.meta.last_page > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between text-sm">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
<button
|
<button
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => setPage((p) => p - 1)}
|
onClick={() => setPage((p) => p - 1)}
|
||||||
className="text-gray-600 disabled:opacity-40 dark:text-gray-400"
|
className="mob-text-btn"
|
||||||
|
style={{ opacity: page <= 1 ? 0.4 : 1 }}
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-500">
|
<span style={{ color: 'var(--mob-text-light)' }}>
|
||||||
Page {page} of {data.meta.last_page}
|
Page {page} of {data.meta.last_page}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
disabled={page >= data.meta.last_page}
|
disabled={page >= data.meta.last_page}
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
className="text-gray-600 disabled:opacity-40 dark:text-gray-400"
|
className="mob-text-btn"
|
||||||
|
style={{ opacity: page >= data.meta.last_page ? 0.4 : 1 }}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { DataTable, type DataTableColumn } from '../../components/shared/DataTable'
|
import { DataTable, type DataTableColumn } from '../../components/shared/DataTable'
|
||||||
import { useConfirm } from '../../components/shared/ConfirmDialog'
|
import { useConfirm } from '../../components/shared/ConfirmDialog'
|
||||||
import { useToast } from '../../components/shared/Toast/ToastProvider'
|
import { useToast } from '../../components/shared/Toast/ToastProvider'
|
||||||
import { usePermission } from '../../hooks/usePermission'
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
import { fetchUsers, deleteUser } from './usersApi'
|
import { fetchUsers, deleteUser } from './usersApi'
|
||||||
import { useUsersRealtime } from './useUsersRealtime'
|
import { useUsersRealtime } from './useUsersRealtime'
|
||||||
import { UserFormModal } from './UserFormModal'
|
import { UserFormModal } from './UserFormModal'
|
||||||
@@ -17,6 +19,8 @@ export function UsersListPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
useUsersRealtime()
|
useUsersRealtime()
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const search = searchParams.get('search') ?? ''
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [sort, setSort] = useState('created_at')
|
const [sort, setSort] = useState('created_at')
|
||||||
const [direction, setDirection] = useState<'asc' | 'desc'>('desc')
|
const [direction, setDirection] = useState<'asc' | 'desc'>('desc')
|
||||||
@@ -24,8 +28,8 @@ export function UsersListPage() {
|
|||||||
const [roleUser, setRoleUser] = useState<User | null>(null)
|
const [roleUser, setRoleUser] = useState<User | null>(null)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['users', { page, sort, direction }],
|
queryKey: ['users', { page, sort, direction, search }],
|
||||||
queryFn: () => fetchUsers({ page, sort, direction }),
|
queryFn: () => fetchUsers({ page, sort, direction, search: search || undefined }),
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSort(key: string) {
|
function handleSort(key: string) {
|
||||||
@@ -37,6 +41,11 @@ export function UsersListPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSearchChange(value: string) {
|
||||||
|
setPage(1)
|
||||||
|
setSearchParams(value ? { search: value } : {})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(user: User) {
|
async function handleDelete(user: User) {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Delete user',
|
title: 'Delete user',
|
||||||
@@ -55,24 +64,24 @@ export function UsersListPage() {
|
|||||||
{ key: 'name', label: 'Name', sortable: true, render: (u) => u.name },
|
{ key: 'name', label: 'Name', sortable: true, render: (u) => u.name },
|
||||||
{ key: 'username', label: 'Username', sortable: true, render: (u) => u.username },
|
{ key: 'username', label: 'Username', sortable: true, render: (u) => u.username },
|
||||||
{ key: 'email', label: 'Email', sortable: true, render: (u) => u.email },
|
{ key: 'email', label: 'Email', sortable: true, render: (u) => u.email },
|
||||||
{ key: 'roles', label: 'Role', render: (u) => u.roles.join(', ') },
|
{ key: 'roles', label: 'Role', render: (u) => <span className="badge text-bg-secondary">{u.roles.join(', ')}</span> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
render: (u) => (
|
render: (u) => (
|
||||||
<div className="flex gap-2">
|
<div className="btn-group btn-group-sm">
|
||||||
{u.can_edit && (
|
{u.can_edit && (
|
||||||
<button onClick={() => setFormUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
|
<button onClick={() => setFormUser(u)} className="btn btn-outline-primary">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{can('roles.assign') && (
|
{can('roles.assign') && (
|
||||||
<button onClick={() => setRoleUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
|
<button onClick={() => setRoleUser(u)} className="btn btn-outline-secondary">
|
||||||
Role
|
Role
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{u.can_delete && (
|
{u.can_delete && (
|
||||||
<button onClick={() => handleDelete(u)} className="text-red-600 hover:underline dark:text-red-400">
|
<button onClick={() => handleDelete(u)} className="btn btn-outline-danger">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -82,15 +91,21 @@ export function UsersListPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<PageHeader title="Users" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Users' }]} />
|
||||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
defaultValue={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="form-control form-control-sm w-auto"
|
||||||
|
placeholder="Search users…"
|
||||||
|
/>
|
||||||
{can('users.create') && (
|
{can('users.create') && (
|
||||||
<button
|
<button onClick={() => setFormUser(null)} className="btn btn-primary btn-sm">
|
||||||
onClick={() => setFormUser(null)}
|
<i className="bi bi-plus-lg me-1"></i>Add user
|
||||||
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Add user
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,11 +122,13 @@ export function UsersListPage() {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{formUser !== undefined && (
|
{formUser !== undefined && (
|
||||||
<UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} />
|
<UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} />
|
||||||
)}
|
)}
|
||||||
{roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />}
|
{roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+10
-5
@@ -1,9 +1,14 @@
|
|||||||
@import "tailwindcss";
|
/* AdminLTE (Bootstrap 5) supplies the base/reset layer for the desktop shell.
|
||||||
|
Tailwind is imported utilities-only (no preflight) so its reset doesn't
|
||||||
|
fight Bootstrap's — both utility systems coexist: AdminLTE/Bootstrap
|
||||||
|
classes drive the desktop dashboard, Tailwind utilities style everything
|
||||||
|
else (mobile shell, modals, forms, login page). */
|
||||||
|
@import "bootstrap-icons/font/bootstrap-icons.min.css";
|
||||||
|
@import "admin-lte/dist/css/adminlte.min.css";
|
||||||
|
|
||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/utilities.css" layer(utilities);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import { Navbar } from '../../components/desktop/Navbar'
|
import { Navbar } from '../../components/desktop/Navbar'
|
||||||
import { Sidebar } from '../../components/desktop/Sidebar'
|
import { Sidebar } from '../../components/desktop/Sidebar'
|
||||||
|
import { Footer } from '../../components/desktop/Footer'
|
||||||
|
|
||||||
|
const BODY_CLASSES = ['layout-fixed', 'sidebar-expand-lg', 'bg-body-tertiary']
|
||||||
|
|
||||||
export function DesktopShell() {
|
export function DesktopShell() {
|
||||||
|
// AdminLTE's layout CSS keys off classes on <body>, not a scoped wrapper —
|
||||||
|
// add them only while the desktop shell is mounted so the mobile shell
|
||||||
|
// (Tailwind-only) isn't affected.
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add(...BODY_CLASSES)
|
||||||
|
return () => document.body.classList.remove(...BODY_CLASSES)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="app-wrapper">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-y-auto bg-gray-50 p-6 dark:bg-gray-950">
|
<main className="app-main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import '../../styles/mobile.css'
|
||||||
import { TopBar } from '../../components/mobile/TopBar'
|
import { TopBar } from '../../components/mobile/TopBar'
|
||||||
import { BottomNav } from '../../components/mobile/BottomNav'
|
import { BottomNav } from '../../components/mobile/BottomNav'
|
||||||
|
import { SlideOutNav } from '../../components/mobile/SlideOutNav'
|
||||||
|
|
||||||
export function MobileShell() {
|
export function MobileShell() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="mob-app">
|
||||||
<TopBar />
|
<TopBar onMenuClick={() => setMenuOpen(true)} />
|
||||||
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 dark:bg-gray-950">
|
<SlideOutNav open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||||
|
<main className="mob-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
|
|||||||
@@ -1,10 +1,46 @@
|
|||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
to: string
|
to: string
|
||||||
|
icon: string
|
||||||
permission?: string
|
permission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export interface NavGroup {
|
||||||
{ label: 'Dashboard', to: '/dashboard', permission: 'dashboard.view' },
|
header: string
|
||||||
{ label: 'Users', to: '/users', permission: 'users.view' },
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
header: 'MAIN',
|
||||||
|
items: [{ label: 'Dashboard', to: '/dashboard', icon: 'bi-speedometer', permission: 'dashboard.view' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'ADMINISTRATION',
|
||||||
|
items: [
|
||||||
|
{ label: 'Users', to: '/users', icon: 'bi-people-fill', permission: 'users.view' },
|
||||||
|
{ label: 'Roles', to: '/roles', icon: 'bi-shield-lock-fill', permission: 'roles.view' },
|
||||||
|
{ label: 'MQTT Settings', to: '/system/mqtt', icon: 'bi-broadcast', permission: 'system.manage' },
|
||||||
|
{ label: 'Backup & Restore', to: '/system/backup', icon: 'bi-database-down', permission: 'system.manage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'WORKSPACE',
|
||||||
|
items: [
|
||||||
|
{ label: 'Profile', to: '/profile', icon: 'bi-person-circle' },
|
||||||
|
{ label: 'Settings', to: '/settings', icon: 'bi-gear-fill' },
|
||||||
|
{ label: 'Calendar', to: '/calendar', icon: 'bi-calendar3' },
|
||||||
|
{ label: 'Chat', to: '/chat', icon: 'bi-chat-dots-fill' },
|
||||||
|
{ label: 'File Manager', to: '/files', icon: 'bi-folder-fill' },
|
||||||
|
{ label: 'Projects', to: '/projects', icon: 'bi-kanban-fill' },
|
||||||
|
{ label: 'Invoice', to: '/invoice', icon: 'bi-receipt' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items)
|
||||||
|
|
||||||
|
// The mobile bottom nav only has room for a handful of icons — cap it to
|
||||||
|
// the items most people need daily rather than flattening all 12.
|
||||||
|
const MOBILE_ITEM_LABELS = ['Dashboard', 'Users', 'Profile', 'Settings']
|
||||||
|
export const MOBILE_NAV_ITEMS: NavItem[] = NAV_ITEMS.filter((item) => MOBILE_ITEM_LABELS.includes(item.label))
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
// Bootstrap's own JS (dropdowns, the navbar/user-menu use data-bs-toggle)
|
||||||
|
// is a separate concern from AdminLTE's plugins below.
|
||||||
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||||
|
// Side-effect import: binds AdminLTE's delegated document-level click
|
||||||
|
// listeners (sidebar toggle, treeview, card widgets, fullscreen, color
|
||||||
|
// mode) once at app startup — works across all client-side route changes.
|
||||||
|
import 'admin-lte'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -2,8 +2,18 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
|
|||||||
import { LoginPage } from '../features/auth/LoginPage'
|
import { LoginPage } from '../features/auth/LoginPage'
|
||||||
import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage'
|
import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage'
|
||||||
import { TwoFactorSetupPage } from '../features/auth/TwoFactorSetupPage'
|
import { TwoFactorSetupPage } from '../features/auth/TwoFactorSetupPage'
|
||||||
import { DashboardPage } from '../features/dashboard/DashboardPage'
|
import { DashboardRoute } from '../features/dashboard/DashboardRoute'
|
||||||
import { UsersPage } from '../features/users/UsersPage'
|
import { UsersPage } from '../features/users/UsersPage'
|
||||||
|
import { RolesPage } from '../features/roles/RolesPage'
|
||||||
|
import { MqttSettingsPage } from '../features/system/MqttSettingsPage'
|
||||||
|
import { BackupRestorePage } from '../features/system/BackupRestorePage'
|
||||||
|
import { SettingsPage } from '../features/settings/SettingsPage'
|
||||||
|
import { ProfilePage } from '../features/profile/ProfilePage'
|
||||||
|
import { InvoicePage } from '../features/invoice/InvoicePage'
|
||||||
|
import { CalendarPage } from '../features/calendar/CalendarPage'
|
||||||
|
import { ChatPage } from '../features/chat/ChatPage'
|
||||||
|
import { FileManagerPage } from '../features/file-manager/FileManagerPage'
|
||||||
|
import { ProjectsPage } from '../features/projects/ProjectsPage'
|
||||||
import { AppShell } from '../layouts/shell/AppShell'
|
import { AppShell } from '../layouts/shell/AppShell'
|
||||||
import { ProtectedRoute } from './ProtectedRoute'
|
import { ProtectedRoute } from './ProtectedRoute'
|
||||||
import { RequirePermission } from './RequirePermission'
|
import { RequirePermission } from './RequirePermission'
|
||||||
@@ -17,12 +27,30 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: '/dashboard', element: <DashboardRoute /> },
|
||||||
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
{ path: '/settings/2fa', element: <TwoFactorSetupPage /> },
|
{ path: '/settings/2fa', element: <TwoFactorSetupPage /> },
|
||||||
|
{ path: '/profile', element: <ProfilePage /> },
|
||||||
|
{ path: '/invoice', element: <InvoicePage /> },
|
||||||
|
{ path: '/calendar', element: <CalendarPage /> },
|
||||||
|
{ path: '/chat', element: <ChatPage /> },
|
||||||
|
{ path: '/files', element: <FileManagerPage /> },
|
||||||
|
{ path: '/projects', element: <ProjectsPage /> },
|
||||||
{
|
{
|
||||||
element: <RequirePermission permission="users.view" />,
|
element: <RequirePermission permission="users.view" />,
|
||||||
children: [{ path: '/users', element: <UsersPage /> }],
|
children: [{ path: '/users', element: <UsersPage /> }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
element: <RequirePermission permission="roles.view" />,
|
||||||
|
children: [{ path: '/roles', element: <RolesPage /> }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <RequirePermission permission="system.manage" />,
|
||||||
|
children: [
|
||||||
|
{ path: '/system/mqtt', element: <MqttSettingsPage /> },
|
||||||
|
{ path: '/system/backup', element: <BackupRestorePage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/*
|
||||||
|
* Mobile design system — entirely separate from the desktop AdminLTE/
|
||||||
|
* Bootstrap styling (frontend/src/index.css). Every selector is scoped
|
||||||
|
* under `.mob-app` and every class is prefixed `mob-` so it can never
|
||||||
|
* collide with or be affected by AdminLTE's global classes (e.g. both
|
||||||
|
* define an "app-sidebar" concept, but under different names/rules).
|
||||||
|
* Adapted from a provided reference design (purple theme, card system,
|
||||||
|
* slide-out sidebar, bottom tab bar).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mob-app {
|
||||||
|
--mob-primary: #6c63ff;
|
||||||
|
--mob-primary-light: #8a84ff;
|
||||||
|
--mob-bg: #f5f5f9;
|
||||||
|
--mob-white: #ffffff;
|
||||||
|
--mob-text-dark: #333333;
|
||||||
|
--mob-text-medium: #555555;
|
||||||
|
--mob-text-light: #999999;
|
||||||
|
--mob-border: #eeeeee;
|
||||||
|
--mob-light-gray-bg: #f7f7f7;
|
||||||
|
--mob-success: #4caf50;
|
||||||
|
--mob-warning: #ff9800;
|
||||||
|
--mob-danger: #f44336;
|
||||||
|
--mob-tab-bar-height: 62px;
|
||||||
|
--mob-padding: 16px;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background-color: var(--mob-bg);
|
||||||
|
color: var(--mob-text-dark);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-app * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.mob-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px var(--mob-padding);
|
||||||
|
padding-top: 14px;
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-bottom: 1px solid var(--mob-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.mob-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.mob-menu-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.mob-menu-btn:hover {
|
||||||
|
background: var(--mob-primary);
|
||||||
|
color: var(--mob-white);
|
||||||
|
}
|
||||||
|
.mob-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.mob-title span:first-child {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mob-title span:last-child {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
}
|
||||||
|
.mob-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mob-icon-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mob-icon-btn:hover {
|
||||||
|
background: var(--mob-primary);
|
||||||
|
color: var(--mob-white);
|
||||||
|
}
|
||||||
|
.mob-badge-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background: var(--mob-danger);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--mob-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide-out sidebar */
|
||||||
|
.mob-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: -280px;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--mob-white);
|
||||||
|
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
transition: left 0.25s ease-out;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.mob-sidebar.mob-open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.mob-sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 55;
|
||||||
|
display: none;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.mob-sidebar-backdrop.mob-show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.mob-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid var(--mob-border);
|
||||||
|
}
|
||||||
|
.mob-sidebar-title {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.mob-sidebar-close {
|
||||||
|
border: none;
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.mob-sidebar-close:hover {
|
||||||
|
background: var(--mob-danger);
|
||||||
|
color: var(--mob-white);
|
||||||
|
}
|
||||||
|
.mob-sidebar-section-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
.mob-sidebar-link {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mob-sidebar-link .mob-nav-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mob-sidebar-link:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(108, 99, 255, 0.1), rgba(108, 99, 255, 0.05));
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
.mob-sidebar-link.mob-active {
|
||||||
|
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
|
||||||
|
color: var(--mob-white);
|
||||||
|
box-shadow: 0 4px 12px rgba(108, 99, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.mob-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px var(--mob-padding);
|
||||||
|
padding-bottom: calc(var(--mob-tab-bar-height) + 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom tab bar */
|
||||||
|
.mob-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: var(--mob-tab-bar-height);
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-top: 1px solid var(--mob-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.mob-tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mob-tab-item .mob-nav-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.mob-tab-item.mob-active {
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI cards */
|
||||||
|
.mob-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
@media (min-width: 540px) {
|
||||||
|
.mob-kpi-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mob-kpi-card {
|
||||||
|
background: var(--mob-white);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
border-left: 5px solid var(--mob-primary);
|
||||||
|
}
|
||||||
|
.mob-kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mob-kpi-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.mob-kpi-change {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mob-kpi-change.mob-positive {
|
||||||
|
color: var(--mob-success);
|
||||||
|
}
|
||||||
|
.mob-kpi-change.mob-negative {
|
||||||
|
color: var(--mob-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section / generic cards */
|
||||||
|
.mob-section-card {
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.06);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.mob-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mob-section-header h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.mob-section-header small {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-card {
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List rows (users, activity, etc.) */
|
||||||
|
.mob-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px dashed var(--mob-border);
|
||||||
|
}
|
||||||
|
.mob-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.mob-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(108, 99, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.mob-status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-success {
|
||||||
|
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-warning {
|
||||||
|
background: linear-gradient(135deg, #fff3e0, #ffe0b2);
|
||||||
|
color: #ef6c00;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-danger {
|
||||||
|
background: linear-gradient(135deg, #ffebee, #ffcdd2);
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-neutral {
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search + filters */
|
||||||
|
.mob-search-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mob-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: 2px solid var(--mob-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mob-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--mob-primary);
|
||||||
|
box-shadow: 0 0 0 4px rgba(108, 99, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.mob-pill-btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
|
||||||
|
color: var(--mob-white);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(108, 99, 255, 0.3);
|
||||||
|
}
|
||||||
|
.mob-pill-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
.mob-pill-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.mob-text-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.mob-text-btn.mob-danger {
|
||||||
|
color: var(--mob-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.mob-app {
|
||||||
|
--mob-bg: #17181f;
|
||||||
|
--mob-white: #23242e;
|
||||||
|
--mob-text-dark: #e6e6f0;
|
||||||
|
--mob-text-medium: #b7b7c7;
|
||||||
|
--mob-text-light: #7d7d8f;
|
||||||
|
--mob-border: #33343f;
|
||||||
|
--mob-light-gray-bg: #2b2c37;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export interface Role {
|
export interface Role {
|
||||||
name: string
|
name: string
|
||||||
|
permissions?: string[]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user