| @@ -0,0 +1,333 @@ | |||
| # SKILLS.md | |||
| ## Purpose | |||
| This is the main skill index for AI coding agents working on **MindVisionCode PHP**. | |||
| Read this file after `AGENTS.md`, then load only the focused skill files needed for the current task. | |||
| --- | |||
| ## Project Overview | |||
| MindVisionCode PHP is a small PHP MVC framework inspired by a Classic ASP MVC framework style. | |||
| The project favors: | |||
| - Central dispatcher | |||
| - Controllers and actions | |||
| - ViewModels | |||
| - Repository classes | |||
| - Simple validation | |||
| - Database migrations | |||
| - Small, readable files | |||
| - Minimal dependencies | |||
| Do not turn this into Laravel, Symfony, Slim, CakePHP, or another large framework. | |||
| --- | |||
| ## Configuration Files | |||
| | File | Purpose | | |||
| |------|---------| | |||
| | `config/database.php` | PDO DSN, credentials, and PDO options | | |||
| | `config/view.php` | Views directory path and layout file path | | |||
| Add new config files to `config/` — never hardcode environment-specific paths in `core/`. | |||
| --- | |||
| ## Tech Stack | |||
| - PHP 8.2+ | |||
| - Composer | |||
| - PSR-4 autoloading | |||
| - PDO | |||
| - PHP views | |||
| - Optional SQLite/MySQL/SQL Server through PDO | |||
| - Minimal dependencies | |||
| --- | |||
| ## Preferred Project Structure | |||
| ```text | |||
| project-root/ | |||
| AGENTS.md | |||
| .ai/ | |||
| SKILLS.md | |||
| skills/ | |||
| php/ | |||
| SKILL.md | |||
| mvc/ | |||
| SKILL.md | |||
| database/ | |||
| SKILL.md | |||
| security/ | |||
| SKILL.md | |||
| testing/ | |||
| SKILL.md | |||
| workflow/ | |||
| SKILL.md | |||
| public/ | |||
| index.php | |||
| src/ | |||
| Controller/ | |||
| Service/ | |||
| Repository/ | |||
| Entity/ | |||
| ValueObject/ | |||
| ViewModel/ | |||
| Http/ | |||
| Routing/ | |||
| Validation/ | |||
| Database/ | |||
| Migration/ | |||
| templates/ | |||
| config/ | |||
| tests/ | |||
| var/ | |||
| cache/ | |||
| logs/ | |||
| vendor/ | |||
| composer.json | |||
| ``` | |||
| Rules: | |||
| - `public/` is the web root. | |||
| - Do not expose `src/`, `config/`, `tests/`, `vendor/`, `.ai/`, or `.env` files through the web server. | |||
| - Put application code under `src/`. | |||
| - Put generated cache/log files under `var/` or another ignored runtime directory. | |||
| - Keep secrets outside the web root. | |||
| --- | |||
| ## Setup Commands | |||
| Install dependencies: | |||
| ```bash | |||
| composer install | |||
| ``` | |||
| Regenerate autoload files: | |||
| ```bash | |||
| composer dump-autoload | |||
| ``` | |||
| Run local server: | |||
| ```bash | |||
| php -S localhost:8000 -t public | |||
| ``` | |||
| Run basic tests: | |||
| ```bash | |||
| php tests/run.php | |||
| ``` | |||
| Run database migrations: | |||
| ```bash | |||
| php scripts/migrate.php up | |||
| ``` | |||
| Roll back the last migration: | |||
| ```bash | |||
| php scripts/migrate.php down | |||
| ``` | |||
| Check migration status: | |||
| ```bash | |||
| php scripts/migrate.php status | |||
| ``` | |||
| Create a new migration file: | |||
| ```bash | |||
| php scripts/migrate.php make <name> | |||
| ``` | |||
| Reset and re-run all migrations: | |||
| ```bash | |||
| php scripts/migrate.php fresh | |||
| php scripts/migrate.php fresh --seed | |||
| ``` | |||
| --- | |||
| ## Request Flow | |||
| ```text | |||
| Browser | |||
| → public/index.php | |||
| → Request | |||
| → Dispatcher | |||
| → Router | |||
| → Route | |||
| → Controller | |||
| → ViewModel/Repository/Service | |||
| → View | |||
| → Response | |||
| ``` | |||
| --- | |||
| ## Skill Routes | |||
| ### PHP Language, Style, Composer, OOP | |||
| Read: | |||
| ```text | |||
| ./.ai/skills/php/SKILL.md | |||
| ``` | |||
| Use for: | |||
| - PHP version decisions | |||
| - PSR standards | |||
| - Composer dependencies | |||
| - Namespaces and autoloading | |||
| - OOP design | |||
| - Dependency injection | |||
| - Documentation and PHPDoc | |||
| - Performance and caching | |||
| --- | |||
| ### MVC Framework Architecture | |||
| Read: | |||
| ```text | |||
| ./.ai/skills/mvc/SKILL.md | |||
| ``` | |||
| Use for: | |||
| - Dispatcher changes | |||
| - Router changes | |||
| - Controllers and actions | |||
| - ViewModels | |||
| - PHP templates/views | |||
| - HTTP request/response flow | |||
| - Framework structure | |||
| --- | |||
| ### Database and Persistence | |||
| Read: | |||
| ```text | |||
| ./.ai/skills/database/SKILL.md | |||
| ``` | |||
| Use for: | |||
| - PDO | |||
| - SQL | |||
| - Repositories | |||
| - Migrations | |||
| - Transactions | |||
| - Database configuration | |||
| - SQLite/MySQL/SQL Server support | |||
| --- | |||
| ### Security | |||
| Read: | |||
| ```text | |||
| ./.ai/skills/security/SKILL.md | |||
| ``` | |||
| Use for: | |||
| - Input validation | |||
| - Output escaping | |||
| - Passwords | |||
| - Authentication | |||
| - Authorization | |||
| - Sessions | |||
| - CSRF | |||
| - Secrets | |||
| - Error disclosure | |||
| - Dangerous functions | |||
| --- | |||
| ### Testing and Quality | |||
| Read: | |||
| ```text | |||
| ./.ai/skills/testing/SKILL.md | |||
| ``` | |||
| Use for: | |||
| - Tests | |||
| - Test runner changes | |||
| - Static analysis | |||
| - Composer quality scripts | |||
| - Code style tools | |||
| - Verification steps | |||
| --- | |||
| ### Agent Workflow | |||
| Read: | |||
| ```text | |||
| ./.ai/skills/workflow/SKILL.md | |||
| ``` | |||
| Use for: | |||
| - Multi-file changes | |||
| - Pull-request style review | |||
| - Legacy PHP changes | |||
| - Non-negotiable rules | |||
| - Response format | |||
| - Skill feedback updates | |||
| --- | |||
| ## Default Coding Rules | |||
| - Keep code simple and readable. | |||
| - Prefer small classes. | |||
| - Use typed properties and return types where practical. | |||
| - Avoid hidden magic. | |||
| - Do not add dependencies without a clear reason. | |||
| - Preserve the framework style. | |||
| - Explain any architectural changes. | |||
| --- | |||
| ## Default Security Rules | |||
| - Validate input. | |||
| - Escape output. | |||
| - Use prepared statements for SQL. | |||
| - Do not expose sensitive errors. | |||
| - Check authorization separately from authentication. | |||
| --- | |||
| ## Default Testing Rules | |||
| - Add or update tests for meaningful behavior changes. | |||
| - Explain how to verify changes. | |||
| - If tests are not added, explain why. | |||
| @@ -0,0 +1,197 @@ | |||
| # Database Skill | |||
| ## Purpose | |||
| Use this skill for PDO, repositories, SQL, migrations, transactions, database configuration, and persistence rules. | |||
| --- | |||
| ## Database Stack | |||
| Preferred database access: | |||
| - PDO | |||
| - Repositories or data access classes | |||
| - Optional SQLite, MySQL, or SQL Server through PDO | |||
| - Prepared statements for all untrusted values | |||
| --- | |||
| ## Database Class API | |||
| `Core\Database` wraps PDO and exposes these methods: | |||
| | Method | Returns | Description | | |||
| |--------|---------|-------------| | |||
| | `query(string $sql, array $params = [])` | `array` | Runs a SELECT and returns all rows as associative arrays | | |||
| | `first(string $sql, array $params = [])` | `?array` | Runs a SELECT and returns the first row, or `null` | | |||
| | `execute(string $sql, array $params = [])` | `bool` | Runs INSERT / UPDATE / DELETE | | |||
| | `lastInsertId()` | `string` | Returns the last auto-increment ID as a string — cast to `int` for integer PKs | | |||
| | `transaction(callable $fn)` | `mixed` | Runs `$fn($db)` inside a transaction; commits on success, rolls back and rethrows on failure | | |||
| | `pdo()` | `PDO` | Returns the raw PDO instance for advanced use | | |||
| `lastInsertId()` is only meaningful immediately after an `execute()` INSERT on the same connection. Calling it at any other point returns `"0"`. | |||
| Typical INSERT + ID retrieval in a repository: | |||
| ```php | |||
| public function create(Employee $employee): int | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO employees (first_name, email) VALUES (:first_name, :email)', | |||
| ['first_name' => $employee->firstName, 'email' => $employee->email] | |||
| ); | |||
| return (int) $this->database->lastInsertId(); | |||
| } | |||
| ``` | |||
| --- | |||
| ## Database Access Rules | |||
| Use PDO or a well-maintained database abstraction layer/ORM. | |||
| Never concatenate untrusted input into SQL. | |||
| Bad: | |||
| ```php | |||
| $sql = "SELECT * FROM users WHERE id = " . $_GET['id']; | |||
| ``` | |||
| Good: | |||
| ```php | |||
| $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id'); | |||
| $stmt->bindValue(':id', $id, PDO::PARAM_INT); | |||
| $stmt->execute(); | |||
| $user = $stmt->fetch(PDO::FETCH_ASSOC); | |||
| ``` | |||
| Rules: | |||
| - Use prepared statements and bound parameters. | |||
| - Validate input before using it in writes. | |||
| - Keep SQL out of templates. | |||
| - Keep database access out of controllers where practical. | |||
| - Use transactions when multiple writes must succeed or fail together. | |||
| - Do not rely only on client-side validation. | |||
| - Do not expose raw database errors to users. | |||
| --- | |||
| ## Transactions | |||
| Use `Database::transaction()` when multiple writes must succeed or fail together. It begins a transaction, runs the callback, commits on success, and rolls back and rethrows on any `Throwable`. | |||
| ```php | |||
| $database->transaction(function (Database $db) use ($order): void { | |||
| $orders->create($order); | |||
| $auditLog->record('order.created', $order->id()); | |||
| }); | |||
| ``` | |||
| Do not call `$database->pdo()->beginTransaction()` directly for transaction management — use `transaction()` instead. Reserve `pdo()` for driver-specific features that have no `Database` API equivalent. | |||
| --- | |||
| ## Repository Rules | |||
| - Put persistence logic in repositories or data access classes. | |||
| - Keep repositories focused around a table, aggregate, or use case. | |||
| - Do not let repositories render HTML. | |||
| - Do not let repositories read directly from `$_GET`, `$_POST`, or other superglobals. | |||
| - Return domain objects, entities, DTOs, arrays, or ViewModels according to existing project convention. | |||
| - Prefer explicit methods such as `findById`, `findAllActive`, and `save` over generic magic calls. | |||
| --- | |||
| ## Migration System | |||
| The migration system is made up of three files: | |||
| - `core/Migration.php` — abstract base class all migration files extend | |||
| - `core/MigrationManager.php` — runs, rolls back, and tracks applied migrations | |||
| - `core/helpers.php` — provides the `migration_manager()` helper that wires a `MigrationManager` to the app database and the `database/migrations/` path | |||
| The CLI entry point is `scripts/migrate.php`. Run it from the project root: | |||
| | Command | Description | | |||
| |---------|-------------| | |||
| | `php scripts/migrate.php up` | Run all pending migrations | | |||
| | `php scripts/migrate.php down [steps]` | Roll back the last N migrations (default: 1) | | |||
| | `php scripts/migrate.php status` | Show which migrations have run and when | | |||
| | `php scripts/migrate.php make <name>` | Scaffold a new timestamped migration file | | |||
| | `php scripts/migrate.php fresh` | Roll back everything and re-run from scratch | | |||
| | `php scripts/migrate.php fresh --seed` | Same as fresh, then run the employee seed | | |||
| Migration files live in `database/migrations/` and are named `YYYYMMDD_HHMMSS_<slug>.php`. | |||
| Each file must return a `Migration` instance: | |||
| ```php | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute('CREATE TABLE ...'); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS ...'); | |||
| } | |||
| }; | |||
| ``` | |||
| --- | |||
| ## Migration Rules | |||
| - Keep migrations small and reversible when practical. | |||
| - Document destructive migrations clearly. | |||
| - Do not mix schema changes with unrelated feature logic. | |||
| - Use project migration conventions before inventing new ones. | |||
| - Support SQLite/MySQL/SQL Server differences explicitly when the project targets multiple engines. | |||
| - Do not use database-specific SQL in framework or migration code. `INSERT OR IGNORE` (SQLite) and `INSERT IGNORE` (MySQL) are not portable — use a check-then-insert pattern instead. | |||
| - Migration records in the `migrations` table are permanent. A row means "this migration ran against this database." Deleting a migration file does not remove the record and does not allow the migration to be re-run. To intentionally re-run a migration, delete its row from the `migrations` table manually — this makes the action explicit. | |||
| - To reset completely, use `php scripts/migrate.php fresh`, which rolls back all migrations in reverse order and re-runs them from scratch. | |||
| --- | |||
| ## SQL Safety Checklist | |||
| Before completing database work, verify: | |||
| - [ ] SQL uses prepared statements or a safe query builder. | |||
| - [ ] Untrusted values are never concatenated into SQL. | |||
| - [ ] Writes validate input server-side. | |||
| - [ ] Multi-step writes use transactions where needed. | |||
| - [ ] Database errors are logged safely and not displayed raw to users. | |||
| - [ ] Schema changes are documented. | |||
| - [ ] Tests or verification steps cover the changed behavior. | |||
| --- | |||
| ## Database Configuration | |||
| - Keep database credentials out of source control. | |||
| - Prefer environment variables or ignored local config files for secrets. | |||
| - Provide safe examples such as `.env.example`. | |||
| - Do not commit production DSNs, passwords, tokens, or private keys. | |||
| Example `.env.example`: | |||
| ```text | |||
| APP_ENV=local | |||
| APP_DEBUG=true | |||
| DATABASE_URL=mysql://user:password@localhost:3306/app | |||
| ``` | |||
| @@ -0,0 +1,321 @@ | |||
| # MVC Framework Skill | |||
| ## Purpose | |||
| Use this skill for MindVisionCode PHP framework architecture, routing, dispatching, controllers, actions, ViewModels, templates, and HTTP request/response flow. | |||
| --- | |||
| ## Project Identity | |||
| This project is a small PHP MVC framework called **MindVisionCode PHP**. | |||
| It is intentionally inspired by a Classic ASP MVC framework style: | |||
| - Central dispatcher | |||
| - Controllers and actions | |||
| - ViewModels | |||
| - Repository classes | |||
| - Simple validation | |||
| - Database migrations | |||
| - Small, readable files | |||
| - Minimal dependencies | |||
| Do not turn this into Laravel, Symfony, Slim, CakePHP, or another large framework. | |||
| --- | |||
| ## Request Flow | |||
| ```text | |||
| Browser | |||
| → public/index.php | |||
| → Request | |||
| → Dispatcher | |||
| → Router | |||
| → Route | |||
| → Controller | |||
| → ViewModel/Repository/Service | |||
| → View | |||
| → Response | |||
| ``` | |||
| --- | |||
| ## Project Structure | |||
| Preferred structure: | |||
| ```text | |||
| project-root/ | |||
| public/ | |||
| index.php | |||
| src/ | |||
| Controller/ | |||
| Service/ | |||
| Repository/ | |||
| Entity/ | |||
| ValueObject/ | |||
| ViewModel/ | |||
| Http/ | |||
| Routing/ | |||
| Validation/ | |||
| Database/ | |||
| Migration/ | |||
| templates/ | |||
| config/ | |||
| tests/ | |||
| var/ | |||
| cache/ | |||
| logs/ | |||
| vendor/ | |||
| composer.json | |||
| ``` | |||
| Rules: | |||
| - `public/` is the web root. | |||
| - Do not expose `src/`, `config/`, `tests/`, `vendor/`, `.ai/`, or `.env` files through the web server. | |||
| - Put application code under `src/`. | |||
| - Put generated cache/log files under `var/` or another ignored runtime directory. | |||
| - Keep secrets outside the web root. | |||
| --- | |||
| ## Development Commands | |||
| Install dependencies: | |||
| ```bash | |||
| composer install | |||
| ``` | |||
| Regenerate autoload files: | |||
| ```bash | |||
| composer dump-autoload | |||
| ``` | |||
| Run local server: | |||
| ```bash | |||
| php -S localhost:8000 -t public | |||
| ``` | |||
| Run basic tests: | |||
| ```bash | |||
| php tests/run.php | |||
| ``` | |||
| --- | |||
| ## Framework Coding Rules | |||
| - Keep code simple and readable. | |||
| - Prefer small classes. | |||
| - Use typed properties and return types where practical. | |||
| - Avoid hidden magic. | |||
| - Do not add dependencies without a clear reason. | |||
| - Preserve the framework style. | |||
| - Explain any architectural changes. | |||
| --- | |||
| ## Service and Request Injection | |||
| `Core\App::resolveArgs()` injects constructor and action parameters by type. Any service registered via `$app->bind(SomeClass::class, $instance)` in `public/index.php` is automatically injected when an action declares a typed parameter of that class. | |||
| `Core\Request` is registered as a binding in `public/index.php` before dispatch, so controller actions can declare it as a parameter without calling `Request::capture()` manually: | |||
| ```php | |||
| public function index(Request $request): Response | |||
| { | |||
| $search = $request->input('search', ''); | |||
| // ... | |||
| } | |||
| ``` | |||
| Route segment parameters (e.g. `{id}`) are still resolved by name before the binding lookup. | |||
| To make a service injectable, register it once at bootstrap: | |||
| ```php | |||
| // public/index.php | |||
| $app->bind(Database::class, database()); | |||
| ``` | |||
| Then declare it as a typed parameter in any action: | |||
| ```php | |||
| public function index(Database $db): Response | |||
| { | |||
| // $db is injected automatically | |||
| } | |||
| ``` | |||
| **Binding concrete instances:** Use `$app->instance($name, $obj)` to bind a specific object by key. Instances take precedence over bindings when resolving. | |||
| **Auto-wiring:** Use `$app->make(SomeClass::class)` to resolve a class with its constructor dependencies injected automatically: | |||
| ```php | |||
| $repo = $app->make(EmployeeRepository::class); | |||
| // $app checks bindings first, then instantiates the class and resolves its constructor | |||
| ``` | |||
| **Test isolation:** Call `$app->clear()` to reset all bindings and instances between test runs. | |||
| Do not call `Request::capture()` inside action bodies. Declare the parameter instead. | |||
| --- | |||
| ## Controller Rules | |||
| - Keep controllers thin. | |||
| - Validate request method and request shape at the boundary. | |||
| - Do not put database query details directly in controllers when a repository or service is more appropriate. | |||
| - Do not put template rendering logic inside business services. | |||
| - Return or produce a response through the framework’s response mechanism. | |||
| - Use `requirePost($request)` to guard POST-only actions. It returns `?Response` (null when the method is POST, a 405 Response otherwise). Always return it immediately if non-null: | |||
| ```php | |||
| if ($guard = $this->requirePost($request)) { | |||
| return $guard; | |||
| } | |||
| ``` | |||
| - Verify CSRF **before** field validation on any state-changing action. CSRF failure is a security gate, not a form validation error. See the Security skill for the helper pattern. | |||
| - When a controller uses a repository across multiple methods, store it as a nullable property and lazy-initialize once — do not call `new Repository(database())` on every method call: | |||
| ```php | |||
| private ?EmployeeRepository $employees = null; | |||
| private function employees(): EmployeeRepository | |||
| { | |||
| if ($this->employees === null) { | |||
| $this->employees = new EmployeeRepository(database()); | |||
| } | |||
| return $this->employees; | |||
| } | |||
| ``` | |||
| --- | |||
| ## ViewModel Rules | |||
| - Use ViewModels to shape data for views. | |||
| - Keep ViewModels simple and explicit. | |||
| - Avoid passing raw database rows directly into complex templates when a ViewModel would make the template clearer. | |||
| - Do not put database access inside ViewModels unless the existing project convention explicitly does that. | |||
| --- | |||
| ## View Configuration | |||
| View paths are set in `config/view.php`: | |||
| ```php | |||
| return [ | |||
| 'views_path' => __DIR__ . '/../app/Views', | |||
| 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', | |||
| ]; | |||
| ``` | |||
| `core/View.php` reads this file lazily on first use and caches the result. To change where views or the layout live, edit `config/view.php` — do not edit `core/View.php`. This follows the same pattern as `config/database.php`. | |||
| --- | |||
| ## Templates and Views | |||
| Keep presentation separate from business logic. | |||
| Rules: | |||
| - Do not query the database from templates. | |||
| - Do not place business rules in templates. | |||
| - Escape output by default. | |||
| - Prefer simple view models or arrays passed into templates. | |||
| - Use a template engine with automatic escaping only if it fits the project and does not make the framework unnecessarily large. | |||
| Plain PHP template example: | |||
| ```php | |||
| <h1><?= e($pageTitle) ?></h1> | |||
| <ul> | |||
| <?php foreach ($users as $user): ?> | |||
| <li><?= e($user->name()) ?></li> | |||
| <?php endforeach; ?> | |||
| </ul> | |||
| ``` | |||
| --- | |||
| ## Router Methods | |||
| `Core\Router` exposes one method per HTTP verb: | |||
| | Method | HTTP verb | | |||
| |--------|-----------| | |||
| | `$router->get($path, $handler)` | GET | | |||
| | `$router->post($path, $handler)` | POST | | |||
| | `$router->put($path, $handler)` | PUT | | |||
| | `$router->patch($path, $handler)` | PATCH | | |||
| | `$router->delete($path, $handler)` | DELETE | | |||
| | `$router->add($method, $path, $handler)` | Any verb | | |||
| --- | |||
| ## Method Override for HTML Forms | |||
| HTML forms only support GET and POST. To route a form submission to a PUT, PATCH, or DELETE handler, add a hidden `_method` field: | |||
| ```html | |||
| <form method="POST" action="/employees/42"> | |||
| <?= csrf_field() ?> | |||
| <input type="hidden" name="_method" value="PUT"> | |||
| <!-- fields --> | |||
| </form> | |||
| ``` | |||
| `Core\Request::method()` checks for this field (and the `X-HTTP-Method-Override` header from JavaScript clients) when the base method is POST, and returns the overridden verb. Only `PUT`, `PATCH`, and `DELETE` are accepted as override values — all others are ignored. | |||
| --- | |||
| ## HTTP and Web Application Rules | |||
| Rules: | |||
| - Use the front controller pattern where appropriate. | |||
| - Keep routing separate from business logic. | |||
| - Validate request methods. | |||
| - Use CSRF protection for state-changing forms. | |||
| - Use proper HTTP status codes. | |||
| - Redirect after successful POST to avoid duplicate form submission. | |||
| - Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy. | |||
| Example POST guard using the framework helper: | |||
| ```php | |||
| public function store(Request $request): Response | |||
| { | |||
| if ($guard = $this->requirePost($request)) { | |||
| return $guard; | |||
| } | |||
| // POST-only logic here | |||
| } | |||
| ``` | |||
| --- | |||
| ## Architecture Guardrail | |||
| When adding features, preserve the small-framework character: | |||
| - Prefer explicit code over hidden convention. | |||
| - Prefer simple routing over complex annotation systems. | |||
| - Prefer plain PHP views unless a project decision says otherwise. | |||
| - Prefer focused services and repositories over large framework abstractions. | |||
| - Do not introduce a large package just to solve a small problem. | |||
| @@ -0,0 +1,331 @@ | |||
| # PHP Skill | |||
| ## Purpose | |||
| Use this skill for PHP language rules, coding style, Composer, namespacing, autoloading, object-oriented design, dependency injection, documentation, and performance. | |||
| Source reference: | |||
| ```text | |||
| https://phptherightway.com/ | |||
| ``` | |||
| --- | |||
| ## Core Philosophy | |||
| Write PHP that is: | |||
| - Readable before clever. | |||
| - Secure by default. | |||
| - Consistent with community standards. | |||
| - Easy to test, debug, and refactor. | |||
| - Separated by responsibility. | |||
| PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style. | |||
| --- | |||
| ## PHP Version Standard | |||
| Use the current stable PHP version supported by the project. | |||
| Default expectation: | |||
| ```text | |||
| PHP 8.2+ | |||
| ``` | |||
| Do not introduce code that depends on unsupported PHP versions unless the project explicitly targets a legacy runtime. | |||
| When adding a language feature, verify that it is supported by the project’s configured PHP version in `composer.json`. | |||
| Example: | |||
| ```json | |||
| { | |||
| "require": { | |||
| "php": ">=8.2" | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Coding Style | |||
| Follow recognized PHP standards unless the repository already defines stricter rules. | |||
| Preferred standards: | |||
| - PSR-1: Basic Coding Standard | |||
| - PSR-12: Extended Coding Style | |||
| - PSR-4: Autoloading | |||
| Use automated tooling rather than manual formatting arguments. | |||
| Recommended tools: | |||
| ```bash | |||
| composer require --dev squizlabs/php_codesniffer | |||
| composer require --dev friendsofphp/php-cs-fixer | |||
| ``` | |||
| Example checks: | |||
| ```bash | |||
| vendor/bin/phpcs --standard=PSR12 src tests | |||
| vendor/bin/php-cs-fixer fix --dry-run --diff | |||
| ``` | |||
| Example fix: | |||
| ```bash | |||
| vendor/bin/php-cs-fixer fix | |||
| ``` | |||
| --- | |||
| ## Naming Rules | |||
| Use English names for code symbols and infrastructure. | |||
| Good: | |||
| ```php | |||
| class InvoiceRepository | |||
| { | |||
| public function findByCustomerId(int $customerId): array | |||
| { | |||
| // ... | |||
| } | |||
| } | |||
| ``` | |||
| Avoid unclear abbreviations: | |||
| ```php | |||
| class InvRepo | |||
| { | |||
| public function fbcid($cid) | |||
| { | |||
| // ... | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Formatting Rules | |||
| - Use `<?php` tags. Do not use short open tags. | |||
| - Use strict types at the top of new PHP files when practical: | |||
| ```php | |||
| declare(strict_types=1); | |||
| ``` | |||
| - One class per file. | |||
| - Match namespaces to directory structure. | |||
| - Keep functions and methods small and focused. | |||
| - Prefer explicit visibility: `public`, `protected`, or `private`. | |||
| - Avoid global state unless required by the framework or legacy integration. | |||
| --- | |||
| ## Namespaces and Autoloading | |||
| All new application classes must use namespaces. | |||
| Use PSR-4 autoloading through Composer. | |||
| Example `composer.json`: | |||
| ```json | |||
| { | |||
| "autoload": { | |||
| "psr-4": { | |||
| "App\\": "src/" | |||
| } | |||
| }, | |||
| "autoload-dev": { | |||
| "psr-4": { | |||
| "Tests\\": "tests/" | |||
| } | |||
| } | |||
| } | |||
| ``` | |||
| After changing autoload rules, run: | |||
| ```bash | |||
| composer dump-autoload | |||
| ``` | |||
| Example class: | |||
| ```php | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Service; | |||
| final class InvoiceCalculator | |||
| { | |||
| public function calculateTotal(array $items): int | |||
| { | |||
| return array_sum(array_column($items, 'amountCents')); | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Dependency Management | |||
| Use Composer for PHP dependencies. | |||
| Rules: | |||
| - Add packages with `composer require` or `composer require --dev`. | |||
| - Commit `composer.json` and `composer.lock` for applications. | |||
| - Do not manually copy vendor libraries into the project. | |||
| - Do not edit files under `vendor/`. | |||
| - Prefer maintained packages with clear documentation, tests, and recent releases. | |||
| - Remove unused packages. | |||
| Commands: | |||
| ```bash | |||
| composer install | |||
| composer update vendor/package | |||
| composer audit | |||
| composer validate | |||
| ``` | |||
| Use `composer update` intentionally. Do not casually update every dependency in unrelated work. | |||
| --- | |||
| ## Object-Oriented Design | |||
| Prefer clear object-oriented code for domain and application logic. | |||
| Example: | |||
| ```php | |||
| final class CustomerName | |||
| { | |||
| public function __construct(private string $value) | |||
| { | |||
| if (trim($value) === '') { | |||
| throw new InvalidArgumentException('Customer name is required.'); | |||
| } | |||
| } | |||
| public function value(): string | |||
| { | |||
| return $this->value; | |||
| } | |||
| } | |||
| ``` | |||
| Guidelines: | |||
| - Keep controllers thin. | |||
| - Put business rules in services or domain objects. | |||
| - Put persistence logic in repositories or data access classes. | |||
| - Use interfaces when multiple implementations are expected or when it improves testing. | |||
| - Avoid huge “utility” classes. | |||
| - Avoid magic methods unless they provide clear framework integration or a documented benefit. | |||
| --- | |||
| ## Dependency Injection | |||
| Prefer dependency injection over creating dependencies inside classes. | |||
| Good: | |||
| ```php | |||
| final class RegisterUser | |||
| { | |||
| public function __construct( | |||
| private UserRepository $users, | |||
| private PasswordHasher $passwords | |||
| ) { | |||
| } | |||
| public function handle(string $email, string $plainPassword): void | |||
| { | |||
| $hash = $this->passwords->hash($plainPassword); | |||
| $this->users->create($email, $hash); | |||
| } | |||
| } | |||
| ``` | |||
| Avoid: | |||
| ```php | |||
| final class RegisterUser | |||
| { | |||
| public function handle(string $email, string $plainPassword): void | |||
| { | |||
| $users = new UserRepository(); | |||
| $passwords = new PasswordHasher(); | |||
| } | |||
| } | |||
| ``` | |||
| Rules: | |||
| - Constructor injection is preferred for required dependencies. | |||
| - Do not use service locators casually. | |||
| - Do not hide dependencies in global variables. | |||
| - Keep dependency containers at application boundaries, not inside domain logic. | |||
| --- | |||
| ## Documentation Rules | |||
| Use PHPDoc where it adds clarity, especially for arrays, complex return values, and public APIs. | |||
| Good: | |||
| ```php | |||
| /** | |||
| * @return list<Customer> | |||
| */ | |||
| public function findActiveCustomers(): array | |||
| { | |||
| // ... | |||
| } | |||
| ``` | |||
| Avoid noisy comments that repeat the code: | |||
| ```php | |||
| // Increment i by one. | |||
| $i++; | |||
| ``` | |||
| Rules: | |||
| - Explain why, not just what. | |||
| - Document non-obvious tradeoffs. | |||
| - Keep README setup instructions current. | |||
| - Update examples when behavior changes. | |||
| --- | |||
| ## Performance and Caching | |||
| Rules: | |||
| - Measure before optimizing. | |||
| - Avoid unnecessary database queries in loops. | |||
| - Use pagination for large result sets. | |||
| - Cache expensive reads where appropriate. | |||
| - Use OPcache in production. | |||
| - Do not cache user-specific sensitive data in shared caches without a clear key strategy. | |||
| @@ -0,0 +1,297 @@ | |||
| # Security Skill | |||
| ## Purpose | |||
| Use this skill for input validation, output escaping, passwords, authentication, authorization, sessions, CSRF, secrets, error disclosure, dangerous functions, serialization, and file/path safety. | |||
| --- | |||
| ## Security Baseline | |||
| Treat all external data as untrusted. | |||
| Untrusted data includes: | |||
| - `$_GET` | |||
| - `$_POST` | |||
| - `$_REQUEST` | |||
| - `$_COOKIE` | |||
| - `$_SERVER` | |||
| - uploaded files | |||
| - request bodies | |||
| - session values | |||
| - database values originally supplied by users | |||
| - third-party API responses | |||
| --- | |||
| ## Input Validation | |||
| Validate on input. Use `Core\Validator` for field validation — it is a fluent chain that collects all errors before returning. | |||
| ### Validator API | |||
| | Method | Description | | |||
| |--------|-------------| | |||
| | `required(field, value, message?)` | Fails if value is null or blank | | |||
| | `maxLength(field, value, max, message?)` | Fails if string length exceeds max | | |||
| | `minLength(field, value, min, message?)` | Fails if string length is below min | | |||
| | `numeric(field, value, message?)` | Fails if value is not numeric | | |||
| | `min(field, value, min, message?)` | Fails if numeric value is below min | | |||
| | `max(field, value, max, message?)` | Fails if numeric value exceeds max | | |||
| | `email(field, value, message?)` | Fails if non-empty value is not a valid email | | |||
| | `date(field, value, format?, message?)` | Fails if non-empty value does not match the date format (default `Y-m-d`) | | |||
| | `in(field, value, allowed[], message?)` | Fails if value is not in the allowed list (strict comparison) | | |||
| | `passes()` | Returns `true` when no errors were collected | | |||
| | `fails()` | Returns `true` when any errors were collected | | |||
| | `errors()` | Returns `array<string, list<string>>` of field errors | | |||
| `email()`, `date()`, `min()`, and `max()` skip empty or non-numeric values respectively — pair them with `required()` or `numeric()` when the field is mandatory. | |||
| Example: | |||
| ```php | |||
| $validator = new Validator(); | |||
| $validator | |||
| ->required('email', $form['email'], 'Email is required.') | |||
| ->maxLength('email', $form['email'], 255) | |||
| ->email('email', $form['email'], 'Enter a valid email address.') | |||
| ->required('start_date', $form['start_date'], 'Start date is required.') | |||
| ->date('start_date', $form['start_date'], 'Y-m-d', 'Enter a valid start date.'); | |||
| if ($validator->fails()) { | |||
| // $validator->errors() returns field => [messages] map | |||
| } | |||
| ``` | |||
| Rules: | |||
| - Validate type, range, length, format, and allowed values. | |||
| - Validate server-side even when client-side validation exists. | |||
| - Reject unexpected fields when appropriate. | |||
| - Normalize data intentionally, not accidentally. | |||
| - Do not reimplement email or date validation inline in controllers — use the Validator methods. | |||
| --- | |||
| ## Output Escaping | |||
| Escape on output based on context. | |||
| For HTML output: | |||
| ```php | |||
| function e(string $value): string | |||
| { | |||
| return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||
| } | |||
| ``` | |||
| Usage: | |||
| ```php | |||
| <p><?= e($user->name()) ?></p> | |||
| ``` | |||
| Rules: | |||
| - Escape based on context: HTML, attribute, JavaScript, CSS, URL, SQL, shell. | |||
| - Do not use the same escaping function for every context. | |||
| - Prefer template engines with automatic escaping only when appropriate for the project. | |||
| - Avoid allowing raw HTML from users. If required, sanitize with a proven whitelist sanitizer. | |||
| - Use `escapeshellarg()` when passing controlled values to shell commands, and avoid shell execution when possible. | |||
| - Never trust file paths supplied by users. | |||
| - Reject path traversal values such as `../`, `/`, `\`, and null bytes when user-provided paths are not allowed. | |||
| --- | |||
| ## Passwords and Authentication | |||
| Never store plain-text passwords. | |||
| Use PHP’s password API: | |||
| ```php | |||
| $hash = password_hash($plainPassword, PASSWORD_DEFAULT); | |||
| if (! password_verify($plainPassword, $hash)) { | |||
| throw new RuntimeException('Invalid credentials.'); | |||
| } | |||
| ``` | |||
| Rules: | |||
| - Use `password_hash()` for new password hashes. | |||
| - Use `password_verify()` for login checks. | |||
| - Use `password_needs_rehash()` when algorithm/cost settings change. | |||
| - Do not create your own password hashing algorithm. | |||
| - Do not use general-purpose hashes like `md5`, `sha1`, or raw `sha256` for passwords. | |||
| - Rate-limit login attempts. | |||
| - Regenerate session IDs after login. | |||
| - Use secure, HTTP-only, SameSite cookies for sessions. | |||
| --- | |||
| ## Authorization | |||
| - Check authorization separately from authentication. | |||
| - Do not assume logged-in means allowed. | |||
| - Enforce permissions server-side. | |||
| - Avoid hiding buttons as the only authorization control. | |||
| - Prefer explicit permission checks near protected actions or service boundaries. | |||
| --- | |||
| ## CSRF | |||
| Use CSRF protection for state-changing forms and unsafe HTTP methods. | |||
| State-changing actions include: | |||
| - Create | |||
| - Update | |||
| - Delete | |||
| - Login/logout state changes | |||
| - Password changes | |||
| - Email changes | |||
| - Permission changes | |||
| When using `_method` override to tunnel PUT, PATCH, or DELETE through a POST form, always include a CSRF token. The override is only honoured for POST requests, and only for the values `PUT`, `PATCH`, and `DELETE` — all other values are rejected by the framework. | |||
| In MindVisionCode PHP, use the built-in helpers from `core/helpers.php`: | |||
| | Helper | Purpose | | |||
| |--------|---------| | |||
| | `csrf_token()` | Generates and persists the token in the session | | |||
| | `csrf_field()` | Outputs a hidden `<input>` carrying the token — use in every state-changing form | | |||
| | `verify_csrf_token(string $token)` | Returns `bool` — call before any business logic in POST actions | | |||
| **Always verify CSRF before field validation and business logic.** A token failure is a security event, not a form validation error. Use a dedicated private method that returns `?Response` and short-circuits the action: | |||
| ```php | |||
| private function verifyCsrf(Request $request): ?Response | |||
| { | |||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||
| return new Response('Your session has expired. Please go back and try again.', 419); | |||
| } | |||
| return null; | |||
| } | |||
| ``` | |||
| Call it as the first thing in the action: | |||
| ```php | |||
| public function store(): Response | |||
| { | |||
| $request = Request::capture(); | |||
| if ($guard = $this->verifyCsrf($request)) { | |||
| return $guard; | |||
| } | |||
| // field validation and business logic follow | |||
| } | |||
| ``` | |||
| --- | |||
| ## Serialization and Data Exchange | |||
| Do not call `unserialize()` on untrusted data. | |||
| Prefer JSON for data exchange: | |||
| ```php | |||
| $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); | |||
| $json = json_encode($data, JSON_THROW_ON_ERROR); | |||
| ``` | |||
| Rules: | |||
| - Use `JSON_THROW_ON_ERROR` for new code. | |||
| - Validate decoded data before using it. | |||
| - Avoid PHP serialization for data that crosses trust boundaries. | |||
| --- | |||
| ## Configuration and Secrets | |||
| Rules: | |||
| - Keep secrets out of source control. | |||
| - Do not commit passwords, API keys, private keys, tokens, or production DSNs. | |||
| - Store configuration outside the public web root. | |||
| - Use environment variables or ignored local config files for secrets. | |||
| - Provide a safe example file such as `.env.example`. | |||
| Example `.gitignore` entries: | |||
| ```text | |||
| .env | |||
| .env.local | |||
| /config/local.php | |||
| /var/cache/ | |||
| /var/log/ | |||
| /vendor/ | |||
| ``` | |||
| --- | |||
| ## Error Handling and Logging | |||
| Development: | |||
| - Show errors locally. | |||
| - Log errors. | |||
| - Use Xdebug when debugging complex issues. | |||
| Production: | |||
| - Do not display errors to users. | |||
| - Log errors to a secure log destination. | |||
| - Return safe, generic error messages. | |||
| - Preserve enough context in logs for troubleshooting. | |||
| Do not leak: | |||
| - stack traces to users | |||
| - SQL statements with secrets | |||
| - environment variables | |||
| - full filesystem paths | |||
| - tokens or passwords | |||
| Example: | |||
| ```php | |||
| try { | |||
| $service->handle($request); | |||
| } catch (Throwable $e) { | |||
| $logger->error('Order processing failed.', [ | |||
| 'exception' => $e, | |||
| 'requestId' => $requestId, | |||
| ]); | |||
| http_response_code(500); | |||
| echo 'An unexpected error occurred.'; | |||
| } | |||
| ``` | |||
| --- | |||
| ## Security Checklist | |||
| Before completing any feature, verify: | |||
| - [ ] All external input is validated. | |||
| - [ ] All output is escaped for the correct context. | |||
| - [ ] SQL uses prepared statements or safe query builders. | |||
| - [ ] Authentication and authorization are checked server-side. | |||
| - [ ] Secrets are not committed. | |||
| - [ ] Errors are not exposed in production responses. | |||
| - [ ] File uploads validate size, extension, MIME type, and storage path. | |||
| - [ ] Passwords use `password_hash()` and `password_verify()`. | |||
| - [ ] CSRF protection exists for state-changing requests. | |||
| - [ ] Dangerous functions are avoided or justified: `eval`, `exec`, `shell_exec`, `system`, `passthru`, `unserialize`. | |||
| - [ ] Dependencies have no known vulnerabilities according to `composer audit`. | |||
| @@ -0,0 +1,162 @@ | |||
| # Testing and Quality Skill | |||
| ## Purpose | |||
| Use this skill for tests, static analysis, quality gates, Composer scripts, verification steps, and code style checks. | |||
| --- | |||
| ## Testing Standard | |||
| Automated tests are expected for new behavior. | |||
| Preferred tools: | |||
| - The project’s existing `tests/run.php` runner | |||
| - PHPUnit, if the project adds or already uses it | |||
| - Pest, if the project already uses it | |||
| Rules: | |||
| - Add or update tests with every meaningful behavior change. | |||
| - Cover success paths and failure paths. | |||
| - Unit-test business logic. | |||
| - Integration-test database and framework wiring where useful. | |||
| - Functional-test important user flows. | |||
| - Avoid relying on `var_dump()` or manual browser testing as the only verification. | |||
| --- | |||
| ## Basic Test Command | |||
| Run basic tests: | |||
| ```bash | |||
| php tests/run.php | |||
| ``` | |||
| If PHPUnit is installed: | |||
| ```bash | |||
| vendor/bin/phpunit | |||
| ``` | |||
| Example PHPUnit test: | |||
| ```php | |||
| final class InvoiceCalculatorTest extends TestCase | |||
| { | |||
| public function testItCalculatesTotalInCents(): void | |||
| { | |||
| $calculator = new InvoiceCalculator(); | |||
| $total = $calculator->calculateTotal([ | |||
| ['amountCents' => 1000], | |||
| ['amountCents' => 2500], | |||
| ]); | |||
| self::assertSame(3500, $total); | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Static Analysis and Quality Gates | |||
| Use static analysis when available. | |||
| Recommended tools: | |||
| ```bash | |||
| composer require --dev phpstan/phpstan | |||
| composer require --dev vimeo/psalm | |||
| ``` | |||
| Common quality commands: | |||
| ```bash | |||
| composer validate | |||
| composer audit | |||
| vendor/bin/phpcs --standard=PSR12 src tests | |||
| vendor/bin/phpunit | |||
| vendor/bin/phpstan analyse src tests | |||
| ``` | |||
| Do not ignore tool failures without documenting why. | |||
| --- | |||
| ## Recommended Style Tools | |||
| ```bash | |||
| composer require --dev squizlabs/php_codesniffer | |||
| composer require --dev friendsofphp/php-cs-fixer | |||
| ``` | |||
| Example checks: | |||
| ```bash | |||
| vendor/bin/phpcs --standard=PSR12 src tests | |||
| vendor/bin/php-cs-fixer fix --dry-run --diff | |||
| ``` | |||
| Example fix: | |||
| ```bash | |||
| vendor/bin/php-cs-fixer fix | |||
| ``` | |||
| --- | |||
| ## Recommended Composer Scripts | |||
| A project may include scripts like this: | |||
| ```json | |||
| { | |||
| "scripts": { | |||
| "test": "phpunit", | |||
| "style": "phpcs --standard=PSR12 src tests", | |||
| "style:fix": "php-cs-fixer fix", | |||
| "analyse": "phpstan analyse src tests", | |||
| "quality": [ | |||
| "@style", | |||
| "@test", | |||
| "@analyse" | |||
| ] | |||
| } | |||
| } | |||
| ``` | |||
| Then run: | |||
| ```bash | |||
| composer quality | |||
| ``` | |||
| --- | |||
| ## Verification Rules | |||
| When completing work: | |||
| - Run relevant checks when possible. | |||
| - Explain any checks that could not be run. | |||
| - If tests are not added, explain why. | |||
| - If a test fails, do not hide the failure. | |||
| - Do not change unrelated tests just to make failures disappear. | |||
| --- | |||
| ## Quality Checklist | |||
| Before considering work complete: | |||
| - [ ] New behavior is tested. | |||
| - [ ] Existing tests pass, or failures are explained. | |||
| - [ ] Code follows PSR-12 or project-specific style. | |||
| - [ ] Composer files are valid. | |||
| - [ ] No unrelated dependency updates were introduced. | |||
| - [ ] Static analysis passes if configured. | |||
| - [ ] Manual verification steps are documented when automated testing is not practical. | |||
| @@ -0,0 +1,126 @@ | |||
| # Agent Workflow Skill | |||
| ## Purpose | |||
| Use this skill for AI agent behavior, multi-file work, pull-request checks, legacy PHP changes, non-negotiable rules, response format, and skill feedback. | |||
| --- | |||
| ## Agent Workflow | |||
| When modifying this codebase, the AI agent must: | |||
| 1. Inspect existing project conventions before adding new patterns. | |||
| 2. Prefer small, focused changes. | |||
| 3. Preserve public behavior unless explicitly asked to change it. | |||
| 4. Add or update tests when behavior changes. | |||
| 5. Run relevant checks when possible. | |||
| 6. Explain any checks that could not be run. | |||
| 7. Avoid introducing new dependencies unless they solve a clear problem. | |||
| 8. Never place secrets in code, tests, fixtures, logs, or documentation. | |||
| 9. Keep generated code consistent with this file and the loaded skill files. | |||
| 10. Leave the repository better organized than it was found. | |||
| --- | |||
| ## Pull Request / Review Checklist | |||
| Before considering work complete: | |||
| - [ ] Code follows PSR-12 or project-specific style. | |||
| - [ ] Namespaces and autoloading are correct. | |||
| - [ ] Composer files are valid. | |||
| - [ ] No unrelated dependency updates were introduced. | |||
| - [ ] New behavior is tested. | |||
| - [ ] Existing tests pass. | |||
| - [ ] SQL is parameterized. | |||
| - [ ] User input is validated. | |||
| - [ ] Output is escaped. | |||
| - [ ] No secrets are committed. | |||
| - [ ] Errors are handled safely. | |||
| - [ ] Documentation was updated where needed. | |||
| --- | |||
| ## Legacy PHP Exception Policy | |||
| If this project contains legacy PHP: | |||
| - Do not rewrite large areas without approval. | |||
| - Add tests around legacy behavior before refactoring. | |||
| - Improve safety incrementally. | |||
| - Replace deprecated patterns as touched. | |||
| - Avoid mixing modernization with unrelated feature work. | |||
| - Document any compatibility constraints. | |||
| Legacy code should still move toward: | |||
| - Composer autoloading | |||
| - Namespaces | |||
| - PDO/prepared statements | |||
| - Centralized configuration | |||
| - Automated tests | |||
| - Safer error handling | |||
| --- | |||
| ## Non-Negotiable Rules | |||
| The agent must not: | |||
| - Commit secrets. | |||
| - Build SQL using untrusted string concatenation. | |||
| - Store plain-text passwords. | |||
| - Use `md5`, `sha1`, or raw fast hashes for passwords. | |||
| - Display production errors to users. | |||
| - `unserialize()` untrusted data. | |||
| - Put database queries in templates. | |||
| - Edit files under `vendor/`. | |||
| - Add dependencies without a clear reason. | |||
| - Ignore failing tests or quality checks without explanation. | |||
| --- | |||
| ## Response Format | |||
| For non-trivial tasks, respond using this structure: | |||
| ```text | |||
| Goal: | |||
| Route: | |||
| Assumptions: | |||
| Plan: | |||
| Implementation: | |||
| Tests: | |||
| Risks: | |||
| ``` | |||
| For simple questions, answer directly. | |||
| --- | |||
| ## Skill Feedback Rule | |||
| If project guidance is missing or unclear, suggest an update. | |||
| ```text | |||
| Suggested SKILLS.md update: | |||
| - Add/update: ... | |||
| - Reason: ... | |||
| ``` | |||
| --- | |||
| ## Final Instruction to Coding Agents | |||
| When in doubt, choose the boring, obvious, secure PHP solution: | |||
| - Composer-managed dependencies | |||
| - PSR-style code | |||
| - Namespaced classes | |||
| - Dependency injection | |||
| - PDO prepared statements | |||
| - Escaped output | |||
| - Tested behavior | |||
| - Clear errors and logs | |||
| - No secrets in source control | |||
| @@ -0,0 +1,29 @@ | |||
| { | |||
| "permissions": { | |||
| "allow": [ | |||
| "PowerShell(\\(Get-Item \"d:\\\\Development\\\\PHP\\\\Mind-Vision-Code\\\\CLAUDE.md\"\\).Target)", | |||
| "Bash(Get-ChildItem -Recurse \"C:\\\\Users\\\\danielc.NTP\\\\AppData\\\\Local\\\\Temp\\\\KCI-KANBAN-inspect\")", | |||
| "Bash(Select-Object FullName)", | |||
| "Bash(Sort-Object FullName)", | |||
| "Bash(find /mnt/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect -type f | sort 2>&1)", | |||
| "Read(//mnt/c/Users/danielc.NTP/AppData/Local/Temp/**)", | |||
| "PowerShell(git clone https://onefortheroadgit.sytes.net/dcovington/KCI-KANBAN \"$env:TEMP\\\\KCI-KANBAN-inspect\" 2>&1; Get-ChildItem -Recurse \"$env:TEMP\\\\KCI-KANBAN-inspect\" | Select-Object FullName | Sort-Object FullName)", | |||
| "PowerShell(Get-ChildItem -Recurse \"$env:TEMP\\\\KCI-KANBAN-inspect\" | Select-Object -ExpandProperty FullName | Sort-Object)", | |||
| "Bash(composer require *)", | |||
| "Bash(php -r \"echo PHP_VERSION;\")", | |||
| "Read(//usr/local/**)", | |||
| "Bash(find /usr /opt /home -name \"composer\" -o -name \"composer.phar\")", | |||
| "Bash(php /d/Development/PHP/PHP-TERRITORY/composer.phar require stevenmaguire/oauth2-keycloak)", | |||
| "Bash(php -m)", | |||
| "Bash(find /c/Users/danielc.NTP/AppData/Local/JetBrains -name \"composer.phar\" 2>/dev/null; find /d/Development -name \"composer.phar\" 2>/dev/null)", | |||
| "Read(//d/Development/**)", | |||
| "Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -m)", | |||
| "Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -d extension_dir=/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/ext -d extension=php_openssl.dll /d/Development/PHP/PHP-TERRITORY/composer.phar require stevenmaguire/oauth2-keycloak)", | |||
| "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/css/kanban.css\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/css/kanban.css\")", | |||
| "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-board.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-board.js\")", | |||
| "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-modal.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-modal.js\")", | |||
| "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-settings.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-settings.js\")", | |||
| "Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -d extension_dir=/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/ext -d extension=php_pdo.dll -d extension=php_pdo_sqlite.dll scripts/migrate.php fresh)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -0,0 +1,4 @@ | |||
| .git | |||
| .ai | |||
| vendor | |||
| database/app.sqlite | |||
| @@ -0,0 +1,19 @@ | |||
| /vendor/ | |||
| .env | |||
| .env.local | |||
| .env.* | |||
| *.log | |||
| .DS_Store | |||
| .idea/ | |||
| .vscode/ | |||
| /public/uploads/ | |||
| /storage/cache/ | |||
| /storage/logs/ | |||
| /database/app.sqlite | |||
| /database/*.sqlite | |||
| /database/*.sqlite-shm | |||
| /database/*.sqlite-wal | |||
| /database/*.sqlite-journal | |||
| .phpunit.result.cache | |||
| Thumbs.db | |||
| @@ -0,0 +1,242 @@ | |||
| # AGENTS.md | |||
| ## Purpose | |||
| Describe how AI coding agents should work in this repository. | |||
| This file is intentionally small. It acts as the repository-level router and startup guide. Detailed rules live in focused files under `./.ai/skills/` and should be loaded only when relevant to the current task. | |||
| --- | |||
| ## Startup Instructions | |||
| Before working on any task: | |||
| 1. Read this `AGENTS.md`. | |||
| 2. Read the main skill index: | |||
| ```text | |||
| ./.ai/SKILLS.md | |||
| ``` | |||
| 3. Load only the relevant skill files for the current task. | |||
| 4. Follow project-specific instructions before general best practices. | |||
| 5. Inspect existing code before introducing new patterns. | |||
| 6. State important assumptions before making major changes. | |||
| 7. Prefer small, focused, reviewable changes. | |||
| --- | |||
| ## Instruction Priority | |||
| When instructions conflict, follow this order: | |||
| 1. User's current request | |||
| 2. Safety, security, privacy, and data-loss prevention | |||
| 3. This `AGENTS.md` | |||
| 4. `./.ai/SKILLS.md` | |||
| 5. Referenced files in `./.ai/skills/` | |||
| 6. Existing project conventions | |||
| 7. General best practices | |||
| --- | |||
| ## Project Summary | |||
| This project is the **KCI Kanban Board** — a print/mail job tracking kanban application built on the **MindVisionCode PHP** framework. | |||
| It was migrated from an ASP Classic implementation (source: `https://onefortheroadgit.sytes.net/dcovington/KCI-KANBAN`). | |||
| ### What this app does | |||
| - Manage multiple kanban **boards** (each board has a slug-based URL) | |||
| - Each board has configurable **columns** (job stages) and **swim lanes** (job categories/priority rows) | |||
| - **Cards** represent print jobs: job_number, job_name, customer_name, delivery_date, quantity, notes, full_note (PrintStream raw data) | |||
| - Cards drag-and-drop between cells (column × lane intersection) via SortableJS | |||
| - Board settings panel: add/rename/delete/reorder columns and swim lanes | |||
| - **PrintStream integration flag** on boards (`import_from_printstream`, `printstream_job_name`) — the actual import script is a separate process not yet ported to PHP | |||
| - Authentication via **Keycloak SSO** (OIDC authorization-code flow) | |||
| ### Domain tables | |||
| | Table | Purpose | | |||
| |-------|---------| | |||
| | `boards` | One row per kanban board | | |||
| | `board_columns` | Columns (stages) belonging to a board | | |||
| | `swim_lanes` | Swim lanes (rows) belonging to a board | | |||
| | `cards` | Job cards placed in a column × lane cell | | |||
| ### Auth pattern | |||
| - `app/Services/AuthService.php` — static helpers: `requireLogin()`, `isLoggedIn()`, `getCurrentUsername()` | |||
| - Page controllers call `if ($guard = AuthService::requireLogin()) return $guard;` | |||
| - JSON API controllers call `if (!AuthService::isLoggedIn()) return $this->json([...], 401);` | |||
| - Keycloak config lives in `config/auth.php`, reads from env vars — see `.env.example` | |||
| ### Repository instantiation pattern | |||
| Repositories are instantiated directly inside controllers using the `database()` helper (not DI container): | |||
| ```php | |||
| private function boards(): BoardRepository | |||
| { | |||
| return new BoardRepository(database()); | |||
| } | |||
| ``` | |||
| ### Route ordering rule | |||
| `/columns/reorder` and `/swimlanes/reorder` **must be registered before** `/columns/{id}` and `/swimlanes/{id}`. If the literal route comes after the param route, "reorder" is treated as an id. See `routes/web.php` comments. | |||
| ### JSON body endpoints | |||
| `ColumnsController::reorder()` and `SwimLanesController::reorder()` receive a JSON array body (not form data). They read it with `json_decode(file_get_contents('php://input'), true)`. Do not try to use `$request->input()` for these. | |||
| ### Board show view | |||
| `BoardsController::show()` uses `$this->fragment()` (not `$this->view()`) because the kanban board is a fully self-contained HTML page with its own `<html>/<head>/<body>` — it does not use the shared `app.php` layout. | |||
| ### Static assets | |||
| - `public/css/kanban.css` — kanban grid layout and card styles (copied from ASP repo) | |||
| - `public/js/kanban-board.js` — grid rendering, drag-drop, search | |||
| - `public/js/kanban-modal.js` — card create/edit modal | |||
| - `public/js/kanban-settings.js` — settings panel (add/rename/delete/reorder columns and lanes) | |||
| The JS posts to `/cards/*`, `/columns/*`, `/swimlanes/*` — the PHP routes must match exactly. | |||
| ### Composer | |||
| Run with the PHP binary found at: | |||
| ``` | |||
| C:\Users\danielc.NTP\AppData\Local\Microsoft\WinGet\Packages\PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe\php.exe | |||
| ``` | |||
| Composer.phar is at `D:\Development\PHP\PHP-TERRITORY\composer.phar`. Requires `-d extension=php_openssl.dll`. | |||
| ### Docker build notes | |||
| The `vendor/` directory is excluded by `.dockerignore`, so `composer install` runs inside the container at build time. The Dockerfile must install `libzip-dev unzip` (apt) and `zip` (PHP ext) **before** the `composer install` step — without them, Composer cannot extract downloaded package archives and exits with code 1. This is already in the Dockerfile. Do not remove those packages if updating the Dockerfile. | |||
| It is intentionally inspired by a Classic ASP MVC framework style: | |||
| - Central dispatcher | |||
| - Controllers and actions | |||
| - ViewModels | |||
| - Repository classes | |||
| - Simple validation | |||
| - Database migrations | |||
| - Small, readable files | |||
| - Minimal dependencies | |||
| Do **not** turn this project into Laravel, Symfony, Slim, CakePHP, or another large framework. | |||
| The goal is to keep the framework understandable, practical, and easy to extend. | |||
| --- | |||
| ## Main Route | |||
| Always start here: | |||
| ```text | |||
| ./.ai/SKILLS.md | |||
| ``` | |||
| Then load only the skill files needed for the task. | |||
| --- | |||
| ## Common Skill Routes | |||
| ```text | |||
| PHP language, style, Composer, OOP: | |||
| ./.ai/skills/php/SKILL.md | |||
| MVC framework architecture, routing, controllers, views, ViewModels: | |||
| ./.ai/skills/mvc/SKILL.md | |||
| PDO, repositories, migrations, SQL, database safety: | |||
| ./.ai/skills/database/SKILL.md | |||
| Input validation, escaping, passwords, sessions, secrets, web security: | |||
| ./.ai/skills/security/SKILL.md | |||
| Tests, static analysis, quality gates, composer scripts: | |||
| ./.ai/skills/testing/SKILL.md | |||
| Agent behavior, PR checklist, legacy policy, response format: | |||
| ./.ai/skills/workflow/SKILL.md | |||
| ``` | |||
| --- | |||
| ## Response Format | |||
| For non-trivial tasks, respond using this structure: | |||
| ```text | |||
| Goal: | |||
| Route: | |||
| Assumptions: | |||
| Plan: | |||
| Implementation: | |||
| Tests: | |||
| Risks: | |||
| ``` | |||
| For simple questions, answer directly. | |||
| --- | |||
| ## Framework Change Policy | |||
| The framework core may be modified to add functionality or optimize existing code, but **never silently**. Any time an agent identifies a change to framework-level code (dispatcher, routing, base controller, base repository, migration runner, validation engine, autoloader, or any file under `core/`), it must stop and present the following proposal to the user before writing a single line: | |||
| ```text | |||
| FRAMEWORK CHANGE PROPOSAL | |||
| ========================== | |||
| Issue: | |||
| What problem or limitation was found, and where in the framework it lives. | |||
| Proposed Change: | |||
| What would be added, modified, or removed. | |||
| Why It Is Needed: | |||
| The specific reason application code cannot solve this without a framework change. | |||
| Risks / Dangers: | |||
| - Breaking changes to existing controllers, repositories, or views | |||
| - Behavioral differences across PHP versions | |||
| - Security surface changes | |||
| - Performance regressions | |||
| - Any other relevant concerns | |||
| Benefits: | |||
| - What improves or is unlocked by the change | |||
| Alternatives Considered: | |||
| Any application-level workarounds that were ruled out and why. | |||
| Ai Agent Skills Update: | |||
| - What skills need to be changed to support this framework-level change? | |||
| Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask questions. | |||
| ``` | |||
| **Rules:** | |||
| - Do not apply the change until the user explicitly approves. | |||
| - If the user says NO, document the limitation as a comment or note and continue with the best available application-level workaround. | |||
| - Keep framework changes small and focused — one concern per change. | |||
| - After approval, note the change in the commit message so the history is clear. | |||
| - Update the proper skill file so that the new process can be applied to all future changes in this repository. | |||
| --- | |||
| ## Skill Feedback Rule | |||
| If project guidance is missing or unclear, suggest an update. | |||
| ```text | |||
| Suggested SKILLS.md update: | |||
| - Add/update: ... | |||
| - Reason: ... | |||
| ``` | |||
| @@ -0,0 +1 @@ | |||
| AGENTS.md | |||
| @@ -0,0 +1,33 @@ | |||
| FROM php:8.5-apache | |||
| # Install pdo_sqlite and enable mod_rewrite | |||
| RUN apt-get update \ | |||
| && apt-get install -y libsqlite3-dev libzip-dev unzip \ | |||
| && rm -rf /var/lib/apt/lists/* \ | |||
| && docker-php-ext-install pdo_sqlite zip \ | |||
| && a2enmod rewrite | |||
| # Install Composer | |||
| COPY --from=composer:latest /usr/bin/composer /usr/bin/composer | |||
| # Configure Apache virtual host | |||
| COPY docker/vhost.conf /etc/apache2/sites-available/000-default.conf | |||
| COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh | |||
| RUN chmod +x /usr/local/bin/entrypoint.sh | |||
| WORKDIR /var/www/html | |||
| # Copy application files | |||
| COPY . . | |||
| # Generate autoloader (no external dependencies — just generates vendor/autoload.php) | |||
| RUN composer install --no-dev --optimize-autoloader --no-interaction | |||
| # Create database directory and set correct permissions | |||
| RUN mkdir -p database \ | |||
| && chown -R www-data:www-data /var/www/html \ | |||
| && chmod 775 database | |||
| EXPOSE 80 | |||
| ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] | |||
| @@ -0,0 +1,80 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Services\AuthService; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| class AuthController extends Controller | |||
| { | |||
| public function login(Request $request): mixed | |||
| { | |||
| $returnTo = trim((string) $request->input('returnTo', '')); | |||
| if ($returnTo !== '') { | |||
| $_SESSION['auth_return_to'] = $returnTo; | |||
| } | |||
| $provider = AuthService::provider(); | |||
| $authUrl = $provider->getAuthorizationUrl(); | |||
| $_SESSION['oauth2_state'] = $provider->getState(); | |||
| return $this->redirect($authUrl); | |||
| } | |||
| public function callback(Request $request): mixed | |||
| { | |||
| $state = (string) $request->input('state', ''); | |||
| $code = (string) $request->input('code', ''); | |||
| if ($state === '' || $state !== ($_SESSION['oauth2_state'] ?? '')) { | |||
| unset($_SESSION['oauth2_state']); | |||
| return $this->view('auth.callback-error', [ | |||
| 'pageTitle' => 'Authentication Error', | |||
| 'error' => 'Invalid state parameter. Please try logging in again.', | |||
| ]); | |||
| } | |||
| unset($_SESSION['oauth2_state']); | |||
| try { | |||
| $provider = AuthService::provider(); | |||
| $token = $provider->getAccessToken('authorization_code', ['code' => $code]); | |||
| $userInfo = AuthService::claimsFromToken($token->getToken()); | |||
| if (empty($userInfo)) { | |||
| throw new \RuntimeException('Access token payload was empty or undecodable.'); | |||
| } | |||
| AuthService::storeUser($userInfo); | |||
| $redirectTo = $_SESSION['auth_return_to'] ?? '/boards'; | |||
| unset($_SESSION['auth_return_to']); | |||
| return $this->redirect($redirectTo); | |||
| } catch (\Throwable $e) { | |||
| error_log('Keycloak callback error: ' . $e->getMessage()); | |||
| $debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); | |||
| $error = $debug | |||
| ? get_class($e) . ': ' . $e->getMessage() | |||
| : 'Authentication failed. Please try again.'; | |||
| return $this->view('auth.callback-error', [ | |||
| 'pageTitle' => 'Authentication Error', | |||
| 'error' => $error, | |||
| ]); | |||
| } | |||
| } | |||
| public function logout(): mixed | |||
| { | |||
| AuthService::clearSession(); | |||
| return $this->redirect(AuthService::logoutUrl()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,204 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\Board; | |||
| use App\Repositories\BoardColumnRepository; | |||
| use App\Repositories\BoardRepository; | |||
| use App\Repositories\CardRepository; | |||
| use App\Repositories\SwimLaneRepository; | |||
| use App\Services\AuthService; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| class BoardsController extends Controller | |||
| { | |||
| private function boards(): BoardRepository | |||
| { | |||
| return new BoardRepository(database()); | |||
| } | |||
| private function columns(): BoardColumnRepository | |||
| { | |||
| return new BoardColumnRepository(database()); | |||
| } | |||
| private function lanes(): SwimLaneRepository | |||
| { | |||
| return new SwimLaneRepository(database()); | |||
| } | |||
| private function cards(): CardRepository | |||
| { | |||
| return new CardRepository(database()); | |||
| } | |||
| public function index(): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| return $guard; | |||
| } | |||
| $boards = $this->boards()->getAll(); | |||
| return $this->view('boards.index', [ | |||
| 'pageTitle' => 'Boards', | |||
| 'boards' => $boards, | |||
| ]); | |||
| } | |||
| public function create(): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| return $guard; | |||
| } | |||
| return $this->view('boards.create', ['pageTitle' => 'New Board']); | |||
| } | |||
| public function store(Request $request): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| return $guard; | |||
| } | |||
| $name = trim((string) $request->input('name', '')); | |||
| if ($name === '') { | |||
| return $this->view('boards.create', [ | |||
| 'pageTitle' => 'New Board', | |||
| 'error' => 'Board name is required.', | |||
| 'old' => $request->all(), | |||
| ]); | |||
| } | |||
| $username = AuthService::getCurrentUsername(); | |||
| $now = date('Y-m-d H:i:s'); | |||
| $board = new Board(); | |||
| $board->name = $name; | |||
| $board->slug = $this->boards()->uniqueSlug($this->generateSlug($name)); | |||
| $board->importFromPrintstream = $request->input('import_from_printstream') === 'on'; | |||
| $board->printstreamJobName = trim((string) $request->input('printstream_job_name', '')); | |||
| $board->createdAt = $now; | |||
| $board->createdBy = $username; | |||
| $board->updatedAt = $now; | |||
| $board->updatedBy = $username; | |||
| $this->boards()->insert($board); | |||
| return $this->redirect('/board/' . $board->slug); | |||
| } | |||
| public function show(string $slug): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| return $guard; | |||
| } | |||
| $board = $this->boards()->findBySlug($slug); | |||
| if ($board === null) { | |||
| return \Core\Response::notFound('Board not found.'); | |||
| } | |||
| $columns = $this->columns()->findByBoardId($board->id); | |||
| $lanes = $this->lanes()->findByBoardId($board->id); | |||
| $allCards = $this->cards()->findByBoardId($board->id); | |||
| $cardsJson = json_encode( | |||
| array_map(fn($c) => $c->toJsonArray(), $allCards), | |||
| JSON_THROW_ON_ERROR | |||
| ); | |||
| return $this->fragment('boards.show', [ | |||
| 'board' => $board, | |||
| 'columns' => $columns, | |||
| 'lanes' => $lanes, | |||
| 'cardsJson' => $cardsJson, | |||
| ]); | |||
| } | |||
| public function edit(string $slug): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| return $guard; | |||
| } | |||
| $board = $this->boards()->findBySlug($slug); | |||
| if ($board === null) { | |||
| return \Core\Response::notFound('Board not found.'); | |||
| } | |||
| return $this->view('boards.edit', [ | |||
| 'pageTitle' => 'Edit Board', | |||
| 'board' => $board, | |||
| ]); | |||
| } | |||
| public function update(Request $request, string $slug): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| return $guard; | |||
| } | |||
| $board = $this->boards()->findBySlug($slug); | |||
| if ($board === null) { | |||
| return \Core\Response::notFound('Board not found.'); | |||
| } | |||
| $newName = trim((string) $request->input('name', '')); | |||
| if ($newName === '') { | |||
| return $this->view('boards.edit', [ | |||
| 'pageTitle' => 'Edit Board', | |||
| 'board' => $board, | |||
| 'error' => 'Board name is required.', | |||
| ]); | |||
| } | |||
| $board->name = $newName; | |||
| $board->slug = $this->boards()->uniqueSlug($this->generateSlug($newName), $board->id); | |||
| $board->importFromPrintstream = $request->input('import_from_printstream') === 'on'; | |||
| $board->printstreamJobName = trim((string) $request->input('printstream_job_name', '')); | |||
| $board->updatedAt = date('Y-m-d H:i:s'); | |||
| $board->updatedBy = AuthService::getCurrentUsername(); | |||
| $this->boards()->update($board); | |||
| return $this->redirect('/board/' . $board->slug); | |||
| } | |||
| public function destroy(string $slug): mixed | |||
| { | |||
| if ($guard = AuthService::requireLogin()) { | |||
| return $guard; | |||
| } | |||
| $board = $this->boards()->findBySlug($slug); | |||
| if ($board === null) { | |||
| return $this->redirect('/boards'); | |||
| } | |||
| $this->cards()->deleteByBoardId($board->id); | |||
| $this->columns()->deleteByBoardId($board->id); | |||
| $this->lanes()->deleteByBoardId($board->id); | |||
| $this->boards()->delete($board->id); | |||
| return $this->redirect('/boards'); | |||
| } | |||
| private function generateSlug(string $text): string | |||
| { | |||
| $slug = strtolower($text); | |||
| $slug = preg_replace('/[^a-z0-9\s-]/', '', $slug) ?? $slug; | |||
| $slug = preg_replace('/[\s-]+/', '-', trim($slug)) ?? $slug; | |||
| return trim($slug, '-'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,132 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\Card; | |||
| use App\Repositories\CardRepository; | |||
| use App\Services\AuthService; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| class CardsController extends Controller | |||
| { | |||
| private function cards(): CardRepository | |||
| { | |||
| return new CardRepository(database()); | |||
| } | |||
| public function store(Request $request): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $boardId = (int) $request->input('board_id', 0); | |||
| $columnId = (int) $request->input('column_id', 0); | |||
| $swimLaneId = (int) $request->input('swim_lane_id', 0); | |||
| if ($boardId === 0 || $columnId === 0 || $swimLaneId === 0) { | |||
| return $this->json(['ok' => false, 'error' => 'board_id, column_id, and swim_lane_id are required']); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| $username = AuthService::getCurrentUsername(); | |||
| $nextPos = $this->cards()->maxPosition($columnId, $swimLaneId) + 1; | |||
| $card = new Card(); | |||
| $card->boardId = $boardId; | |||
| $card->columnId = $columnId; | |||
| $card->swimLaneId = $swimLaneId; | |||
| $card->jobNumber = trim((string) $request->input('job_number', '')); | |||
| $card->jobName = trim((string) $request->input('job_name', '')); | |||
| $card->customerName = trim((string) $request->input('customer_name', '')); | |||
| $card->deliveryDate = trim((string) $request->input('delivery_date', '')) ?: null; | |||
| $card->quantity = trim((string) $request->input('quantity', '')); | |||
| $card->notes = trim((string) $request->input('notes', '')); | |||
| $card->fullNote = (string) $request->input('full_note', ''); | |||
| $card->position = $nextPos; | |||
| $card->createdAt = $now; | |||
| $card->createdBy = $username; | |||
| $card->updatedAt = $now; | |||
| $card->updatedBy = $username; | |||
| try { | |||
| $this->cards()->insert($card); | |||
| } catch (\Throwable $e) { | |||
| return $this->json(['ok' => false, 'error' => $e->getMessage()]); | |||
| } | |||
| return $this->json(['ok' => true] + $card->toJsonArray()); | |||
| } | |||
| public function update(Request $request, int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $card = $this->cards()->findById($id); | |||
| if ($card === null) { | |||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||
| } | |||
| $card->jobNumber = trim((string) $request->input('job_number', '')); | |||
| $card->jobName = trim((string) $request->input('job_name', '')); | |||
| $card->customerName = trim((string) $request->input('customer_name', '')); | |||
| $card->deliveryDate = trim((string) $request->input('delivery_date', '')) ?: null; | |||
| $card->quantity = trim((string) $request->input('quantity', '')); | |||
| $card->notes = trim((string) $request->input('notes', '')); | |||
| $card->fullNote = (string) $request->input('full_note', ''); | |||
| $card->updatedAt = date('Y-m-d H:i:s'); | |||
| $card->updatedBy = AuthService::getCurrentUsername(); | |||
| try { | |||
| $this->cards()->update($card); | |||
| } catch (\Throwable $e) { | |||
| return $this->json(['ok' => false, 'error' => $e->getMessage()]); | |||
| } | |||
| return $this->json(['ok' => true] + $card->toJsonArray()); | |||
| } | |||
| public function move(Request $request, int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $columnId = (int) $request->input('column_id', 0); | |||
| $swimLaneId = (int) $request->input('swim_lane_id', 0); | |||
| $position = (int) $request->input('position', 0); | |||
| $now = date('Y-m-d H:i:s'); | |||
| $username = AuthService::getCurrentUsername(); | |||
| $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); | |||
| $siblings = trim((string) $request->input('sibling_ids', '')); | |||
| if ($siblings !== '') { | |||
| foreach (explode(',', $siblings) as $idx => $sibId) { | |||
| $sibId = (int) trim($sibId); | |||
| if ($sibId > 0) { | |||
| $this->cards()->updatePosition($sibId, $idx, $now, $username); | |||
| } | |||
| } | |||
| } | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| public function destroy(int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $this->cards()->delete($id); | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,120 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\BoardColumn; | |||
| use App\Repositories\BoardColumnRepository; | |||
| use App\Repositories\CardRepository; | |||
| use App\Services\AuthService; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| class ColumnsController extends Controller | |||
| { | |||
| private function columns(): BoardColumnRepository | |||
| { | |||
| return new BoardColumnRepository(database()); | |||
| } | |||
| private function cards(): CardRepository | |||
| { | |||
| return new CardRepository(database()); | |||
| } | |||
| public function store(Request $request): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $boardId = (int) $request->input('board_id', 0); | |||
| $name = trim((string) $request->input('name', '')); | |||
| if ($boardId === 0 || $name === '') { | |||
| return $this->json(['ok' => false, 'error' => 'board_id and name are required']); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| $username = AuthService::getCurrentUsername(); | |||
| $col = new BoardColumn(); | |||
| $col->boardId = $boardId; | |||
| $col->name = $name; | |||
| $col->position = $this->columns()->maxPosition($boardId) + 1; | |||
| $col->createdAt = $now; | |||
| $col->createdBy = $username; | |||
| $col->updatedAt = $now; | |||
| $col->updatedBy = $username; | |||
| $this->columns()->insert($col); | |||
| return $this->json(['ok' => true, 'id' => $col->id, 'name' => $col->name, 'position' => $col->position]); | |||
| } | |||
| public function update(Request $request, int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $name = trim((string) $request->input('name', '')); | |||
| if ($name === '') { | |||
| return $this->json(['ok' => false, 'error' => 'name is required']); | |||
| } | |||
| $row = $this->columns()->find($id); | |||
| if ($row === null) { | |||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||
| } | |||
| $col = \App\Models\BoardColumn::fromRow($row); | |||
| $col->name = $name; | |||
| $col->updatedAt = date('Y-m-d H:i:s'); | |||
| $col->updatedBy = AuthService::getCurrentUsername(); | |||
| $this->columns()->update($col); | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| public function destroy(int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $this->cards()->deleteByColumnId($id); | |||
| $this->columns()->delete($id); | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| public function reorder(): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $raw = file_get_contents('php://input'); | |||
| $items = json_decode((string) $raw, true); | |||
| if (!is_array($items)) { | |||
| return $this->json(['ok' => false, 'error' => 'Invalid JSON payload']); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| $username = AuthService::getCurrentUsername(); | |||
| foreach ($items as $item) { | |||
| $colId = (int) ($item['id'] ?? 0); | |||
| $position = (int) ($item['position'] ?? 0); | |||
| if ($colId > 0) { | |||
| $this->columns()->updatePosition($colId, $position, $now, $username); | |||
| } | |||
| } | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use Core\Controller; | |||
| class HomeController extends Controller | |||
| { | |||
| public function index(): mixed | |||
| { | |||
| return $this->redirect('/boards'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,120 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\SwimLane; | |||
| use App\Repositories\CardRepository; | |||
| use App\Repositories\SwimLaneRepository; | |||
| use App\Services\AuthService; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| class SwimLanesController extends Controller | |||
| { | |||
| private function lanes(): SwimLaneRepository | |||
| { | |||
| return new SwimLaneRepository(database()); | |||
| } | |||
| private function cards(): CardRepository | |||
| { | |||
| return new CardRepository(database()); | |||
| } | |||
| public function store(Request $request): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $boardId = (int) $request->input('board_id', 0); | |||
| $name = trim((string) $request->input('name', '')); | |||
| if ($boardId === 0 || $name === '') { | |||
| return $this->json(['ok' => false, 'error' => 'board_id and name are required']); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| $username = AuthService::getCurrentUsername(); | |||
| $lane = new SwimLane(); | |||
| $lane->boardId = $boardId; | |||
| $lane->name = $name; | |||
| $lane->position = $this->lanes()->maxPosition($boardId) + 1; | |||
| $lane->createdAt = $now; | |||
| $lane->createdBy = $username; | |||
| $lane->updatedAt = $now; | |||
| $lane->updatedBy = $username; | |||
| $this->lanes()->insert($lane); | |||
| return $this->json(['ok' => true, 'id' => $lane->id, 'name' => $lane->name, 'position' => $lane->position]); | |||
| } | |||
| public function update(Request $request, int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $name = trim((string) $request->input('name', '')); | |||
| if ($name === '') { | |||
| return $this->json(['ok' => false, 'error' => 'name is required']); | |||
| } | |||
| $row = $this->lanes()->find($id); | |||
| if ($row === null) { | |||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||
| } | |||
| $lane = SwimLane::fromRow($row); | |||
| $lane->name = $name; | |||
| $lane->updatedAt = date('Y-m-d H:i:s'); | |||
| $lane->updatedBy = AuthService::getCurrentUsername(); | |||
| $this->lanes()->update($lane); | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| public function destroy(int $id): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $this->cards()->deleteBySwimLaneId($id); | |||
| $this->lanes()->delete($id); | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| public function reorder(): mixed | |||
| { | |||
| if (!AuthService::isLoggedIn()) { | |||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||
| } | |||
| $raw = file_get_contents('php://input'); | |||
| $items = json_decode((string) $raw, true); | |||
| if (!is_array($items)) { | |||
| return $this->json(['ok' => false, 'error' => 'Invalid JSON payload']); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| $username = AuthService::getCurrentUsername(); | |||
| foreach ($items as $item) { | |||
| $laneId = (int) ($item['id'] ?? 0); | |||
| $position = (int) ($item['position'] ?? 0); | |||
| if ($laneId > 0) { | |||
| $this->lanes()->updatePosition($laneId, $position, $now, $username); | |||
| } | |||
| } | |||
| return $this->json(['ok' => true]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class Board | |||
| { | |||
| public int $id = 0; | |||
| public string $name = ''; | |||
| public string $slug = ''; | |||
| public bool $importFromPrintstream = false; | |||
| public string $printstreamJobName = ''; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| public string $updatedBy = ''; | |||
| public static function fromRow(array $row): self | |||
| { | |||
| $model = new self(); | |||
| $model->id = (int) ($row['id'] ?? 0); | |||
| $model->name = (string) ($row['name'] ?? ''); | |||
| $model->slug = (string) ($row['slug'] ?? ''); | |||
| $model->importFromPrintstream = (bool) ($row['import_from_printstream'] ?? false); | |||
| $model->printstreamJobName = (string) ($row['printstream_job_name'] ?? ''); | |||
| $model->createdAt = $row['created_at'] ?? null; | |||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||
| $model->updatedAt = $row['updated_at'] ?? null; | |||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||
| return $model; | |||
| } | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class BoardColumn | |||
| { | |||
| public int $id = 0; | |||
| public int $boardId = 0; | |||
| public string $name = ''; | |||
| public int $position = 0; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| public string $updatedBy = ''; | |||
| public static function fromRow(array $row): self | |||
| { | |||
| $model = new self(); | |||
| $model->id = (int) ($row['id'] ?? 0); | |||
| $model->boardId = (int) ($row['board_id'] ?? 0); | |||
| $model->name = (string) ($row['name'] ?? ''); | |||
| $model->position = (int) ($row['position'] ?? 0); | |||
| $model->createdAt = $row['created_at'] ?? null; | |||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||
| $model->updatedAt = $row['updated_at'] ?? null; | |||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||
| return $model; | |||
| } | |||
| } | |||
| @@ -0,0 +1,65 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class Card | |||
| { | |||
| public int $id = 0; | |||
| public int $boardId = 0; | |||
| public int $columnId = 0; | |||
| public int $swimLaneId = 0; | |||
| public string $jobNumber = ''; | |||
| public string $jobName = ''; | |||
| public string $customerName = ''; | |||
| public ?string $deliveryDate = null; | |||
| public string $quantity = ''; | |||
| public string $notes = ''; | |||
| public string $fullNote = ''; | |||
| public int $position = 0; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| public string $updatedBy = ''; | |||
| public static function fromRow(array $row): self | |||
| { | |||
| $model = new self(); | |||
| $model->id = (int) ($row['id'] ?? 0); | |||
| $model->boardId = (int) ($row['board_id'] ?? 0); | |||
| $model->columnId = (int) ($row['column_id'] ?? 0); | |||
| $model->swimLaneId = (int) ($row['swim_lane_id'] ?? 0); | |||
| $model->jobNumber = (string) ($row['job_number'] ?? ''); | |||
| $model->jobName = (string) ($row['job_name'] ?? ''); | |||
| $model->customerName = (string) ($row['customer_name'] ?? ''); | |||
| $model->deliveryDate = $row['delivery_date'] ?: null; | |||
| $model->quantity = (string) ($row['quantity'] ?? ''); | |||
| $model->notes = (string) ($row['notes'] ?? ''); | |||
| $model->fullNote = (string) ($row['full_note'] ?? ''); | |||
| $model->position = (int) ($row['position'] ?? 0); | |||
| $model->createdAt = $row['created_at'] ?? null; | |||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||
| $model->updatedAt = $row['updated_at'] ?? null; | |||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||
| return $model; | |||
| } | |||
| public function toJsonArray(): array | |||
| { | |||
| return [ | |||
| 'id' => $this->id, | |||
| 'column_id' => $this->columnId, | |||
| 'swim_lane_id' => $this->swimLaneId, | |||
| 'job_number' => $this->jobNumber, | |||
| 'job_name' => $this->jobName, | |||
| 'customer_name' => $this->customerName, | |||
| 'delivery_date' => $this->deliveryDate, | |||
| 'quantity' => $this->quantity, | |||
| 'notes' => $this->notes, | |||
| 'full_note' => $this->fullNote, | |||
| 'position' => $this->position, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class SwimLane | |||
| { | |||
| public int $id = 0; | |||
| public int $boardId = 0; | |||
| public string $name = ''; | |||
| public int $position = 0; | |||
| public ?string $createdAt = null; | |||
| public string $createdBy = ''; | |||
| public ?string $updatedAt = null; | |||
| public string $updatedBy = ''; | |||
| public static function fromRow(array $row): self | |||
| { | |||
| $model = new self(); | |||
| $model->id = (int) ($row['id'] ?? 0); | |||
| $model->boardId = (int) ($row['board_id'] ?? 0); | |||
| $model->name = (string) ($row['name'] ?? ''); | |||
| $model->position = (int) ($row['position'] ?? 0); | |||
| $model->createdAt = $row['created_at'] ?? null; | |||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||
| $model->updatedAt = $row['updated_at'] ?? null; | |||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||
| return $model; | |||
| } | |||
| } | |||
| @@ -0,0 +1,77 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\BoardColumn; | |||
| use Core\Repository; | |||
| class BoardColumnRepository extends Repository | |||
| { | |||
| protected string $table = 'board_columns'; | |||
| /** @return BoardColumn[] */ | |||
| public function findByBoardId(int $boardId): array | |||
| { | |||
| $rows = $this->database->query( | |||
| 'SELECT * FROM board_columns WHERE board_id = :board_id ORDER BY position ASC', | |||
| ['board_id' => $boardId] | |||
| ); | |||
| return array_map(fn(array $r) => BoardColumn::fromRow($r), $rows); | |||
| } | |||
| public function maxPosition(int $boardId): int | |||
| { | |||
| $row = $this->database->first( | |||
| 'SELECT MAX(position) AS max_pos FROM board_columns WHERE board_id = :board_id', | |||
| ['board_id' => $boardId] | |||
| ); | |||
| return (int) ($row['max_pos'] ?? -1); | |||
| } | |||
| public function insert(BoardColumn $col): BoardColumn | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO board_columns (board_id, name, position, created_at, created_by, updated_at, updated_by) | |||
| VALUES (:board_id, :name, :position, :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| 'board_id' => $col->boardId, | |||
| 'name' => $col->name, | |||
| 'position' => $col->position, | |||
| 'created_at' => $col->createdAt, | |||
| 'created_by' => $col->createdBy, | |||
| 'updated_at' => $col->updatedAt, | |||
| 'updated_by' => $col->updatedBy, | |||
| ] | |||
| ); | |||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||
| $col->id = (int) ($row['id'] ?? 0); | |||
| return $col; | |||
| } | |||
| public function update(BoardColumn $col): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE board_columns SET name = :name, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||
| ['name' => $col->name, 'updated_at' => $col->updatedAt, 'updated_by' => $col->updatedBy, 'id' => $col->id] | |||
| ); | |||
| } | |||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE board_columns SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||
| ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||
| ); | |||
| } | |||
| public function deleteByBoardId(int $boardId): void | |||
| { | |||
| $this->database->execute('DELETE FROM board_columns WHERE board_id = :board_id', ['board_id' => $boardId]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,105 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\Board; | |||
| use Core\Repository; | |||
| class BoardRepository extends Repository | |||
| { | |||
| protected string $table = 'boards'; | |||
| public function findBySlug(string $slug): ?Board | |||
| { | |||
| $row = $this->database->first( | |||
| 'SELECT * FROM boards WHERE slug = :slug', | |||
| ['slug' => $slug] | |||
| ); | |||
| return $row ? Board::fromRow($row) : null; | |||
| } | |||
| /** @return Board[] */ | |||
| public function getAll(): array | |||
| { | |||
| $rows = $this->database->query('SELECT * FROM boards ORDER BY name ASC'); | |||
| return array_map(fn(array $r) => Board::fromRow($r), $rows); | |||
| } | |||
| public function slugExists(string $slug, int $excludeId = 0): bool | |||
| { | |||
| if ($excludeId > 0) { | |||
| $row = $this->database->first( | |||
| 'SELECT COUNT(*) AS cnt FROM boards WHERE slug = :slug AND id != :id', | |||
| ['slug' => $slug, 'id' => $excludeId] | |||
| ); | |||
| } else { | |||
| $row = $this->database->first( | |||
| 'SELECT COUNT(*) AS cnt FROM boards WHERE slug = :slug', | |||
| ['slug' => $slug] | |||
| ); | |||
| } | |||
| return (int) ($row['cnt'] ?? 0) > 0; | |||
| } | |||
| public function uniqueSlug(string $base, int $excludeId = 0): string | |||
| { | |||
| $candidate = $base; | |||
| $suffix = 2; | |||
| while ($this->slugExists($candidate, $excludeId)) { | |||
| $candidate = $base . '-' . $suffix; | |||
| $suffix++; | |||
| } | |||
| return $candidate; | |||
| } | |||
| public function insert(Board $board): Board | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO boards | |||
| (name, slug, import_from_printstream, printstream_job_name, created_at, created_by, updated_at, updated_by) | |||
| VALUES | |||
| (:name, :slug, :import_from_printstream, :printstream_job_name, :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| 'name' => $board->name, | |||
| 'slug' => $board->slug, | |||
| 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, | |||
| 'printstream_job_name' => $board->printstreamJobName, | |||
| 'created_at' => $board->createdAt, | |||
| 'created_by' => $board->createdBy, | |||
| 'updated_at' => $board->updatedAt, | |||
| 'updated_by' => $board->updatedBy, | |||
| ] | |||
| ); | |||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||
| $board->id = (int) ($row['id'] ?? 0); | |||
| return $board; | |||
| } | |||
| public function update(Board $board): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE boards | |||
| SET name = :name, slug = :slug, import_from_printstream = :import_from_printstream, | |||
| printstream_job_name = :printstream_job_name, updated_at = :updated_at, updated_by = :updated_by | |||
| WHERE id = :id', | |||
| [ | |||
| 'name' => $board->name, | |||
| 'slug' => $board->slug, | |||
| 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, | |||
| 'printstream_job_name' => $board->printstreamJobName, | |||
| 'updated_at' => $board->updatedAt, | |||
| 'updated_by' => $board->updatedBy, | |||
| 'id' => $board->id, | |||
| ] | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,131 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\Card; | |||
| use Core\Repository; | |||
| class CardRepository extends Repository | |||
| { | |||
| protected string $table = 'cards'; | |||
| public function findById(int $id): ?Card | |||
| { | |||
| $row = $this->database->first('SELECT * FROM cards WHERE id = :id', ['id' => $id]); | |||
| return $row ? Card::fromRow($row) : null; | |||
| } | |||
| /** @return Card[] */ | |||
| public function findByBoardId(int $boardId): array | |||
| { | |||
| $rows = $this->database->query( | |||
| 'SELECT * FROM cards WHERE board_id = :board_id ORDER BY swim_lane_id ASC, column_id ASC, position ASC', | |||
| ['board_id' => $boardId] | |||
| ); | |||
| return array_map(fn(array $r) => Card::fromRow($r), $rows); | |||
| } | |||
| public function maxPosition(int $columnId, int $swimLaneId): int | |||
| { | |||
| $row = $this->database->first( | |||
| 'SELECT MAX(position) AS max_pos FROM cards WHERE column_id = :col AND swim_lane_id = :lane', | |||
| ['col' => $columnId, 'lane' => $swimLaneId] | |||
| ); | |||
| return (int) ($row['max_pos'] ?? -1); | |||
| } | |||
| public function insert(Card $card): Card | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO cards | |||
| (board_id, column_id, swim_lane_id, job_number, job_name, customer_name, delivery_date, | |||
| quantity, notes, full_note, position, created_at, created_by, updated_at, updated_by) | |||
| VALUES | |||
| (:board_id, :column_id, :swim_lane_id, :job_number, :job_name, :customer_name, :delivery_date, | |||
| :quantity, :notes, :full_note, :position, :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| 'board_id' => $card->boardId, | |||
| 'column_id' => $card->columnId, | |||
| 'swim_lane_id' => $card->swimLaneId, | |||
| 'job_number' => $card->jobNumber, | |||
| 'job_name' => $card->jobName, | |||
| 'customer_name' => $card->customerName, | |||
| 'delivery_date' => $card->deliveryDate ?: null, | |||
| 'quantity' => $card->quantity !== '' ? $card->quantity : null, | |||
| 'notes' => $card->notes, | |||
| 'full_note' => $card->fullNote, | |||
| 'position' => $card->position, | |||
| 'created_at' => $card->createdAt, | |||
| 'created_by' => $card->createdBy, | |||
| 'updated_at' => $card->updatedAt, | |||
| 'updated_by' => $card->updatedBy, | |||
| ] | |||
| ); | |||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||
| $card->id = (int) ($row['id'] ?? 0); | |||
| return $card; | |||
| } | |||
| public function update(Card $card): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE cards | |||
| SET job_number = :job_number, job_name = :job_name, customer_name = :customer_name, | |||
| delivery_date = :delivery_date, quantity = :quantity, notes = :notes, full_note = :full_note, | |||
| updated_at = :updated_at, updated_by = :updated_by | |||
| WHERE id = :id', | |||
| [ | |||
| 'job_number' => $card->jobNumber, | |||
| 'job_name' => $card->jobName, | |||
| 'customer_name' => $card->customerName, | |||
| 'delivery_date' => $card->deliveryDate ?: null, | |||
| 'quantity' => $card->quantity !== '' ? $card->quantity : null, | |||
| 'notes' => $card->notes, | |||
| 'full_note' => $card->fullNote, | |||
| 'updated_at' => $card->updatedAt, | |||
| 'updated_by' => $card->updatedBy, | |||
| 'id' => $card->id, | |||
| ] | |||
| ); | |||
| } | |||
| public function move(int $id, int $columnId, int $swimLaneId, int $position, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE cards SET column_id = :column_id, swim_lane_id = :swim_lane_id, position = :position, | |||
| updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||
| ['column_id' => $columnId, 'swim_lane_id' => $swimLaneId, 'position' => $position, | |||
| 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||
| ); | |||
| } | |||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE cards SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||
| ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||
| ); | |||
| } | |||
| public function deleteByBoardId(int $boardId): void | |||
| { | |||
| $this->database->execute('DELETE FROM cards WHERE board_id = :board_id', ['board_id' => $boardId]); | |||
| } | |||
| public function deleteByColumnId(int $columnId): void | |||
| { | |||
| $this->database->execute('DELETE FROM cards WHERE column_id = :column_id', ['column_id' => $columnId]); | |||
| } | |||
| public function deleteBySwimLaneId(int $swimLaneId): void | |||
| { | |||
| $this->database->execute('DELETE FROM cards WHERE swim_lane_id = :swim_lane_id', ['swim_lane_id' => $swimLaneId]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,77 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\SwimLane; | |||
| use Core\Repository; | |||
| class SwimLaneRepository extends Repository | |||
| { | |||
| protected string $table = 'swim_lanes'; | |||
| /** @return SwimLane[] */ | |||
| public function findByBoardId(int $boardId): array | |||
| { | |||
| $rows = $this->database->query( | |||
| 'SELECT * FROM swim_lanes WHERE board_id = :board_id ORDER BY position ASC', | |||
| ['board_id' => $boardId] | |||
| ); | |||
| return array_map(fn(array $r) => SwimLane::fromRow($r), $rows); | |||
| } | |||
| public function maxPosition(int $boardId): int | |||
| { | |||
| $row = $this->database->first( | |||
| 'SELECT MAX(position) AS max_pos FROM swim_lanes WHERE board_id = :board_id', | |||
| ['board_id' => $boardId] | |||
| ); | |||
| return (int) ($row['max_pos'] ?? -1); | |||
| } | |||
| public function insert(SwimLane $lane): SwimLane | |||
| { | |||
| $this->database->execute( | |||
| 'INSERT INTO swim_lanes (board_id, name, position, created_at, created_by, updated_at, updated_by) | |||
| VALUES (:board_id, :name, :position, :created_at, :created_by, :updated_at, :updated_by)', | |||
| [ | |||
| 'board_id' => $lane->boardId, | |||
| 'name' => $lane->name, | |||
| 'position' => $lane->position, | |||
| 'created_at' => $lane->createdAt, | |||
| 'created_by' => $lane->createdBy, | |||
| 'updated_at' => $lane->updatedAt, | |||
| 'updated_by' => $lane->updatedBy, | |||
| ] | |||
| ); | |||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||
| $lane->id = (int) ($row['id'] ?? 0); | |||
| return $lane; | |||
| } | |||
| public function update(SwimLane $lane): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE swim_lanes SET name = :name, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||
| ['name' => $lane->name, 'updated_at' => $lane->updatedAt, 'updated_by' => $lane->updatedBy, 'id' => $lane->id] | |||
| ); | |||
| } | |||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||
| { | |||
| $this->database->execute( | |||
| 'UPDATE swim_lanes SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||
| ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||
| ); | |||
| } | |||
| public function deleteByBoardId(int $boardId): void | |||
| { | |||
| $this->database->execute('DELETE FROM swim_lanes WHERE board_id = :board_id', ['board_id' => $boardId]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,121 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Services; | |||
| use Core\Response; | |||
| use Stevenmaguire\OAuth2\Client\Provider\Keycloak; | |||
| class AuthService | |||
| { | |||
| private static function config(): array | |||
| { | |||
| static $config = null; | |||
| if ($config === null) { | |||
| $config = require __DIR__ . '/../../config/auth.php'; | |||
| } | |||
| return $config['keycloak']; | |||
| } | |||
| public static function provider(): Keycloak | |||
| { | |||
| $cfg = self::config(); | |||
| return new Keycloak([ | |||
| 'authServerUrl' => rtrim($cfg['base_url'], '/'), | |||
| 'realm' => $cfg['realm'], | |||
| 'clientId' => $cfg['client_id'], | |||
| 'clientSecret' => $cfg['client_secret'], | |||
| 'redirectUri' => $cfg['redirect_uri'], | |||
| ]); | |||
| } | |||
| /** | |||
| * Decode user claims from the access token JWT payload. | |||
| * Avoids calling the userinfo endpoint, which Keycloak may return as a | |||
| * signed JWT (application/jwt) rather than JSON — causing decryption errors. | |||
| * | |||
| * @return array<string, mixed> | |||
| */ | |||
| public static function claimsFromToken(string $jwt): array | |||
| { | |||
| $parts = explode('.', $jwt); | |||
| if (count($parts) < 2) { | |||
| return []; | |||
| } | |||
| $payload = base64_decode(strtr($parts[1], '-_', '+/'), true); | |||
| if ($payload === false) { | |||
| return []; | |||
| } | |||
| $data = json_decode($payload, true); | |||
| return is_array($data) ? $data : []; | |||
| } | |||
| public static function requireLogin(): ?Response | |||
| { | |||
| if (!self::isLoggedIn()) { | |||
| $_SESSION['auth_return_to'] = $_SERVER['REQUEST_URI'] ?? '/'; | |||
| return Response::redirect('/auth/login'); | |||
| } | |||
| return null; | |||
| } | |||
| public static function isLoggedIn(): bool | |||
| { | |||
| return !empty($_SESSION['auth_user']); | |||
| } | |||
| public static function getCurrentUser(): array | |||
| { | |||
| return $_SESSION['auth_user'] ?? []; | |||
| } | |||
| public static function getCurrentUsername(): string | |||
| { | |||
| $user = self::getCurrentUser(); | |||
| return $user['preferred_username'] ?? $user['email'] ?? ''; | |||
| } | |||
| public static function storeUser(array $userInfo): void | |||
| { | |||
| $_SESSION['auth_user'] = $userInfo; | |||
| } | |||
| public static function clearSession(): void | |||
| { | |||
| $_SESSION = []; | |||
| if (ini_get('session.use_cookies')) { | |||
| $params = session_get_cookie_params(); | |||
| setcookie( | |||
| session_name(), | |||
| '', | |||
| time() - 42000, | |||
| $params['path'], | |||
| $params['domain'], | |||
| $params['secure'], | |||
| $params['httponly'] | |||
| ); | |||
| } | |||
| session_destroy(); | |||
| } | |||
| public static function logoutUrl(): string | |||
| { | |||
| $cfg = self::config(); | |||
| $base = rtrim($cfg['base_url'], '/'); | |||
| $realm = $cfg['realm']; | |||
| $postLogout = urlencode($cfg['post_logout_redirect_uri']); | |||
| return "{$base}/realms/{$realm}/protocol/openid-connect/logout?redirect_uri={$postLogout}"; | |||
| } | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| <div class="text-center py-5"> | |||
| <h1 class="h3 text-danger mb-3">Authentication Error</h1> | |||
| <p class="text-muted mb-4"><?= e($error ?? 'An authentication error occurred.') ?></p> | |||
| <a href="/auth/login" class="btn btn-primary">Try Again</a> | |||
| </div> | |||
| @@ -0,0 +1,39 @@ | |||
| <div class="row justify-content-center"> | |||
| <div class="col-md-6"> | |||
| <h1 class="h3 mb-4">New Board</h1> | |||
| <?php if (!empty($error)): ?> | |||
| <div class="alert alert-danger"><?= e($error) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/boards"> | |||
| <?= csrf_field() ?> | |||
| <div class="mb-3"> | |||
| <label for="name" class="form-label">Board Name <span class="text-danger">*</span></label> | |||
| <input type="text" class="form-control" id="name" name="name" | |||
| value="<?= e((string) ($old['name'] ?? '')) ?>" required autofocus /> | |||
| </div> | |||
| <div class="mb-3 form-check"> | |||
| <input type="checkbox" class="form-check-input" id="import_from_printstream" | |||
| name="import_from_printstream" | |||
| <?= !empty($old['import_from_printstream']) ? 'checked' : '' ?> /> | |||
| <label class="form-check-label" for="import_from_printstream"> | |||
| Import from PrintStream | |||
| </label> | |||
| </div> | |||
| <div class="mb-4"> | |||
| <label for="printstream_job_name" class="form-label">PrintStream Job Name</label> | |||
| <input type="text" class="form-control" id="printstream_job_name" name="printstream_job_name" | |||
| value="<?= e((string) ($old['printstream_job_name'] ?? '')) ?>" /> | |||
| </div> | |||
| <div class="d-flex gap-2"> | |||
| <button type="submit" class="btn btn-primary">Create Board</button> | |||
| <a href="/boards" class="btn btn-outline-secondary">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,47 @@ | |||
| <div class="row justify-content-center"> | |||
| <div class="col-md-6"> | |||
| <h1 class="h3 mb-4">Edit Board</h1> | |||
| <?php if (!empty($error)): ?> | |||
| <div class="alert alert-danger"><?= e($error) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/board/<?= e($board->slug) ?>/update"> | |||
| <?= csrf_field() ?> | |||
| <div class="mb-3"> | |||
| <label for="name" class="form-label">Board Name <span class="text-danger">*</span></label> | |||
| <input type="text" class="form-control" id="name" name="name" | |||
| value="<?= e($board->name) ?>" required autofocus /> | |||
| </div> | |||
| <div class="mb-3 form-check"> | |||
| <input type="checkbox" class="form-check-input" id="import_from_printstream" | |||
| name="import_from_printstream" | |||
| <?= $board->importFromPrintstream ? 'checked' : '' ?> /> | |||
| <label class="form-check-label" for="import_from_printstream"> | |||
| Import from PrintStream | |||
| </label> | |||
| </div> | |||
| <div class="mb-4"> | |||
| <label for="printstream_job_name" class="form-label">PrintStream Job Name</label> | |||
| <input type="text" class="form-control" id="printstream_job_name" name="printstream_job_name" | |||
| value="<?= e($board->printstreamJobName) ?>" /> | |||
| </div> | |||
| <div class="d-flex gap-2"> | |||
| <button type="submit" class="btn btn-primary">Save Changes</button> | |||
| <a href="/board/<?= e($board->slug) ?>" class="btn btn-outline-secondary">Cancel</a> | |||
| </div> | |||
| </form> | |||
| <hr class="my-4" /> | |||
| <form method="POST" action="/board/<?= e($board->slug) ?>/delete" | |||
| onsubmit="return confirm('Delete this board and ALL its columns, lanes, and cards?')"> | |||
| <?= csrf_field() ?> | |||
| <button type="submit" class="btn btn-danger btn-sm">Delete Board</button> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,33 @@ | |||
| <div class="d-flex justify-content-between align-items-center mb-4"> | |||
| <h1 class="h3 mb-0">Boards</h1> | |||
| <a href="/boards/create" class="btn btn-primary"> | |||
| <i class="bi bi-plus-lg me-1"></i>New Board | |||
| </a> | |||
| </div> | |||
| <?php if (empty($boards)): ?> | |||
| <div class="text-center py-5 text-muted"> | |||
| <i class="bi bi-kanban display-4 d-block mb-3"></i> | |||
| <p class="mb-3">No boards yet.</p> | |||
| <a href="/boards/create" class="btn btn-primary">Create your first board</a> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="row g-3"> | |||
| <?php foreach ($boards as $board): ?> | |||
| <div class="col-sm-6 col-md-4 col-lg-3"> | |||
| <div class="card h-100 shadow-sm"> | |||
| <div class="card-body d-flex flex-column"> | |||
| <h5 class="card-title"><?= e($board->name) ?></h5> | |||
| <p class="card-text text-muted small mb-3"><code><?= e($board->slug) ?></code></p> | |||
| <div class="mt-auto d-flex gap-2"> | |||
| <a href="/board/<?= e($board->slug) ?>" class="btn btn-sm btn-primary flex-grow-1">Open</a> | |||
| <a href="/board/<?= e($board->slug) ?>/edit" class="btn btn-sm btn-outline-secondary"> | |||
| <i class="bi bi-pencil"></i> | |||
| </a> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <?php endforeach; ?> | |||
| </div> | |||
| <?php endif; ?> | |||
| @@ -0,0 +1,99 @@ | |||
| <!doctype html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="utf-8" /> | |||
| <title><?= e($board->name) ?> — Kanban</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" /> | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> | |||
| <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600&display=swap" rel="stylesheet" /> | |||
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |||
| <link href="/css/site.css" rel="stylesheet" /> | |||
| <link href="/css/kanban.css" rel="stylesheet" /> | |||
| </head> | |||
| <body class="kanban-page"> | |||
| <!-- Top bar --> | |||
| <nav class="navbar navbar-dark rk-topnav px-3 py-2"> | |||
| <div class="d-flex align-items-center gap-3 flex-grow-1 board-header-main"> | |||
| <a href="/boards" class="btn btn-sm btn-outline-secondary text-white border-secondary"> | |||
| <i class="bi bi-arrow-left"></i> | |||
| </a> | |||
| <span class="navbar-brand mb-0 h5 kanban-board-title"><?= e($board->name) ?></span> | |||
| </div> | |||
| <div class="board-header-search"> | |||
| <label for="job-search-input" class="visually-hidden">Search jobs</label> | |||
| <div class="input-group input-group-sm"> | |||
| <span class="input-group-text"><i class="bi bi-search"></i></span> | |||
| <input type="search" id="job-search-input" class="form-control" | |||
| placeholder="Search job #, name, customer..." autocomplete="off" /> | |||
| </div> | |||
| </div> | |||
| <div class="d-flex align-items-center gap-2 board-header-actions"> | |||
| <button class="btn btn-sm btn-outline-light" id="btn-add-card" | |||
| data-board-id="<?= e((string) $board->id) ?>"> | |||
| <i class="bi bi-plus-lg me-1"></i>Add Card | |||
| </button> | |||
| <button class="btn btn-sm btn-outline-light" id="btn-settings" title="Board Settings"> | |||
| <i class="bi bi-gear"></i> | |||
| </button> | |||
| <a href="/auth/logout" class="btn btn-sm btn-outline-light" title="Sign Out"> | |||
| <i class="bi bi-box-arrow-right"></i> | |||
| </a> | |||
| </div> | |||
| </nav> | |||
| <!-- Kanban grid --> | |||
| <div class="kanban-wrapper"> | |||
| <div class="kanban-grid" id="kanban-grid"> | |||
| <div class="kanban-corner"></div> | |||
| <?php foreach ($columns as $col): ?> | |||
| <div class="kanban-col-header" data-col-id="<?= e((string) $col->id) ?>"> | |||
| <span class="col-label"><?= e($col->name) ?></span> | |||
| </div> | |||
| <?php endforeach; ?> | |||
| <?php foreach ($lanes as $lane): ?> | |||
| <div class="kanban-lane-header" data-lane-id="<?= e((string) $lane->id) ?>"> | |||
| <button type="button" class="lane-toggle" | |||
| title="Collapse or expand swim lane" | |||
| aria-label="Collapse or expand swim lane" | |||
| aria-expanded="true"> | |||
| <i class="bi bi-chevron-down" aria-hidden="true"></i> | |||
| </button> | |||
| <span class="lane-label"><?= e($lane->name) ?></span> | |||
| </div> | |||
| <?php foreach ($columns as $col): ?> | |||
| <div class="kanban-cell" | |||
| data-col-id="<?= e((string) $col->id) ?>" | |||
| data-lane-id="<?= e((string) $lane->id) ?>"> | |||
| </div> | |||
| <?php endforeach; ?> | |||
| <?php endforeach; ?> | |||
| </div> | |||
| </div> | |||
| <?php require __DIR__ . '/../partials/card-modal.php'; ?> | |||
| <?php require __DIR__ . '/../partials/settings-panel.php'; ?> | |||
| <script> | |||
| var KANBAN = { | |||
| boardId: <?= (int) $board->id ?>, | |||
| boardSlug: "<?= e($board->slug) ?>", | |||
| cards: <?= $cardsJson ?> | |||
| }; | |||
| var KANBAN_COLS = <?= json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position], $columns)) ?>; | |||
| var KANBAN_LANES = <?= json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position], $lanes)) ?>; | |||
| </script> | |||
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | |||
| <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script> | |||
| <script src="/js/kanban-modal.js"></script> | |||
| <script src="/js/kanban-settings.js"></script> | |||
| <script src="/js/kanban-board.js"></script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,39 @@ | |||
| <section class="hero"> | |||
| <div class="hero-copy"> | |||
| <span class="eyebrow"><?= e($model->eyebrow) ?></span> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p class="hero-text"><?= e($model->message) ?></p> | |||
| <div class="hero-actions"> | |||
| <a class="button button-primary" href="<?= e($model->routeExample) ?>">Open Employee Form</a> | |||
| <a class="button button-secondary" href="#framework-highlights">See Highlights</a> | |||
| </div> | |||
| </div> | |||
| <aside class="hero-panel" aria-label="Framework route example"> | |||
| <p class="panel-label">Request Flow</p> | |||
| <code>Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View</code> | |||
| <div class="route-callout"> | |||
| <span>Employee entry page</span> | |||
| <a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a> | |||
| </div> | |||
| </aside> | |||
| </section> | |||
| <section class="feature-grid" id="framework-highlights"> | |||
| <article class="feature-card"> | |||
| <h2>Readable by design</h2> | |||
| <p>Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.</p> | |||
| </article> | |||
| <article class="feature-card"> | |||
| <h2>Classic MVC feel</h2> | |||
| <p>Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.</p> | |||
| </article> | |||
| <article class="feature-card"> | |||
| <h2>SQLite ready</h2> | |||
| <p>Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.</p> | |||
| </article> | |||
| </section> | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| require __DIR__ . '/../partials/header.php'; | |||
| ?> | |||
| <main class="page-content"> | |||
| <div class="container"> | |||
| <?= $content ?> | |||
| </div> | |||
| </main> | |||
| <?php require __DIR__ . '/../partials/footer.php'; ?> | |||
| @@ -0,0 +1,54 @@ | |||
| <!-- Card create/edit modal --> | |||
| <div class="modal fade" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true"> | |||
| <div class="modal-dialog modal-dialog-centered"> | |||
| <div class="modal-content"> | |||
| <div class="modal-header"> | |||
| <h5 class="modal-title" id="cardModalLabel">Add Card</h5> | |||
| <button type="button" class="btn-close" data-bs-dismiss="modal"></button> | |||
| </div> | |||
| <div class="modal-body"> | |||
| <input type="hidden" id="card-id" value="" /> | |||
| <input type="hidden" id="card-column-id" value="" /> | |||
| <input type="hidden" id="card-lane-id" value="" /> | |||
| <div class="mb-3"> | |||
| <label for="card-job-number" class="form-label">Job Number</label> | |||
| <input type="text" class="form-control" id="card-job-number" placeholder="e.g. 10042" /> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <label for="card-job-name" class="form-label">Job Name</label> | |||
| <input type="text" class="form-control" id="card-job-name" placeholder="e.g. Smith Residence" /> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <label for="card-customer-name" class="form-label">Customer</label> | |||
| <input type="text" class="form-control" id="card-customer-name" /> | |||
| </div> | |||
| <div class="row g-2 mb-3"> | |||
| <div class="col"> | |||
| <label for="card-delivery-date" class="form-label">Delivery Date</label> | |||
| <input type="date" class="form-control" id="card-delivery-date" /> | |||
| </div> | |||
| <div class="col"> | |||
| <label for="card-quantity" class="form-label">Quantity</label> | |||
| <input type="text" class="form-control" id="card-quantity" /> | |||
| </div> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <label for="card-notes" class="form-label">Notes</label> | |||
| <textarea class="form-control" id="card-notes" rows="3"></textarea> | |||
| </div> | |||
| <div class="mb-3" id="card-full-note-wrap"> | |||
| <label for="card-full-note" class="form-label">PrintStream Notes</label> | |||
| <textarea class="form-control" id="card-full-note" rows="4" readonly></textarea> | |||
| </div> | |||
| <div id="card-modal-error" class="alert alert-danger d-none"></div> | |||
| </div> | |||
| <div class="modal-footer"> | |||
| <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | |||
| <button type="button" class="btn btn-danger d-none" id="btn-delete-card">Delete</button> | |||
| <button type="button" class="btn btn-primary" id="btn-save-card">Save</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,9 @@ | |||
| <footer class="site-footer"> | |||
| <div class="container footer-inner"> | |||
| <p>MindVisionCode PHP keeps the framework small, readable, and ready for real features.</p> | |||
| <p>© <?= e((string) date('Y')) ?> MindVisionCode</p> | |||
| </div> | |||
| </footer> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,43 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| $navigationItems = [ | |||
| ['label' => 'Boards', 'href' => '/boards'], | |||
| ]; | |||
| $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | |||
| $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; | |||
| ?> | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
| <title><?= e($pageTitle ?? 'KCI Kanban') ?></title> | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" /> | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> | |||
| <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> | |||
| </head> | |||
| <body> | |||
| <div class="page-shell"> | |||
| <header class="site-header"> | |||
| <div class="container header-inner"> | |||
| <a class="brand" href="/boards"> | |||
| <span class="brand-mark">KC</span> | |||
| <span class="brand-copy"> | |||
| <strong>KCI Kanban</strong> | |||
| <small>PHP</small> | |||
| </span> | |||
| </a> | |||
| <nav class="site-nav" aria-label="Primary navigation"> | |||
| <?php foreach ($navigationItems as $item): ?> | |||
| <?php $isActive = str_starts_with($currentPath, $item['href']); ?> | |||
| <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | |||
| <?= e($item['label']) ?> | |||
| </a> | |||
| <?php endforeach; ?> | |||
| </nav> | |||
| </div> | |||
| </header> | |||
| @@ -0,0 +1,79 @@ | |||
| <!-- Settings slide-out panel --> | |||
| <div id="settings-overlay" class="kanban-settings-overlay d-none"></div> | |||
| <div id="settings-panel" class="kanban-settings-panel"> | |||
| <div class="settings-header d-flex justify-content-between align-items-center p-3 border-bottom"> | |||
| <h6 class="mb-0">Board Settings</h6> | |||
| <button class="btn btn-sm btn-outline-secondary" id="btn-close-settings"> | |||
| <i class="bi bi-x-lg"></i> | |||
| </button> | |||
| </div> | |||
| <div class="settings-body p-3"> | |||
| <!-- Columns section --> | |||
| <div class="mb-4"> | |||
| <div class="d-flex justify-content-between align-items-center mb-2"> | |||
| <strong class="small">Columns</strong> | |||
| <button class="btn btn-sm btn-outline-primary" id="btn-add-column"> | |||
| <i class="bi bi-plus"></i> Add | |||
| </button> | |||
| </div> | |||
| <div id="col-add-form" class="d-none mb-2"> | |||
| <div class="input-group input-group-sm"> | |||
| <input type="text" class="form-control" id="col-add-input" placeholder="Column name" /> | |||
| <button class="btn btn-primary" id="btn-col-add-save">Add</button> | |||
| <button class="btn btn-outline-secondary" id="btn-col-add-cancel">Cancel</button> | |||
| </div> | |||
| </div> | |||
| <ul class="list-group settings-sortable" id="col-list"> | |||
| <?php foreach ($columns as $col): ?> | |||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||
| data-id="<?= e((string) $col->id) ?>"> | |||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||
| <span class="flex-grow-1 col-label-text"><?= e($col->name) ?></span> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-col" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| </li> | |||
| <?php endforeach; ?> | |||
| </ul> | |||
| </div> | |||
| <!-- Swim lanes section --> | |||
| <div class="mb-2"> | |||
| <div class="d-flex justify-content-between align-items-center mb-2"> | |||
| <strong class="small">Swim Lanes</strong> | |||
| <button class="btn btn-sm btn-outline-primary" id="btn-add-lane"> | |||
| <i class="bi bi-plus"></i> Add | |||
| </button> | |||
| </div> | |||
| <div id="lane-add-form" class="d-none mb-2"> | |||
| <div class="input-group input-group-sm"> | |||
| <input type="text" class="form-control" id="lane-add-input" placeholder="Swim lane name" /> | |||
| <button class="btn btn-primary" id="btn-lane-add-save">Add</button> | |||
| <button class="btn btn-outline-secondary" id="btn-lane-add-cancel">Cancel</button> | |||
| </div> | |||
| </div> | |||
| <ul class="list-group settings-sortable" id="lane-list"> | |||
| <?php foreach ($lanes as $lane): ?> | |||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||
| data-id="<?= e((string) $lane->id) ?>"> | |||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||
| <span class="flex-grow-1 lane-label-text"><?= e($lane->name) ?></span> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-lane" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-lane" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| </li> | |||
| <?php endforeach; ?> | |||
| </ul> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,24 @@ | |||
| { | |||
| "name": "kci/mindvisioncode", | |||
| "description": "A small PHP MVC framework inspired by a Classic ASP MVC framework.", | |||
| "type": "project", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "App\\": "app/", | |||
| "Core\\": "core/" | |||
| }, | |||
| "files": [ | |||
| "core/helpers.php" | |||
| ] | |||
| }, | |||
| "scripts": { | |||
| "migrate": "php scripts/migrate.php up", | |||
| "migrate:down": "php scripts/migrate.php down", | |||
| "migrate:status": "php scripts/migrate.php status", | |||
| "migrate:fresh": "php scripts/migrate.php fresh", | |||
| "migrate:fresh-seed": "php scripts/migrate.php fresh --seed" | |||
| }, | |||
| "require": { | |||
| "stevenmaguire/oauth2-keycloak": "^6.1" | |||
| } | |||
| } | |||
| @@ -0,0 +1,812 @@ | |||
| { | |||
| "_readme": [ | |||
| "This file locks the dependencies of your project to a known state", | |||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||
| "This file is @generated automatically" | |||
| ], | |||
| "content-hash": "60e95733c2ba68d85ecc8ec7828e8d39", | |||
| "packages": [ | |||
| { | |||
| "name": "firebase/php-jwt", | |||
| "version": "v7.0.5", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/googleapis/php-jwt.git", | |||
| "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", | |||
| "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "guzzlehttp/guzzle": "^7.4", | |||
| "phpfastcache/phpfastcache": "^9.2", | |||
| "phpspec/prophecy-phpunit": "^2.0", | |||
| "phpunit/phpunit": "^9.5", | |||
| "psr/cache": "^2.0||^3.0", | |||
| "psr/http-client": "^1.0", | |||
| "psr/http-factory": "^1.0" | |||
| }, | |||
| "suggest": { | |||
| "ext-sodium": "Support EdDSA (Ed25519) signatures", | |||
| "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Firebase\\JWT\\": "src" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "BSD-3-Clause" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Neuman Vong", | |||
| "email": "neuman+pear@twilio.com", | |||
| "role": "Developer" | |||
| }, | |||
| { | |||
| "name": "Anant Narayanan", | |||
| "email": "anant@php.net", | |||
| "role": "Developer" | |||
| } | |||
| ], | |||
| "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", | |||
| "homepage": "https://github.com/firebase/php-jwt", | |||
| "keywords": [ | |||
| "jwt", | |||
| "php" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/googleapis/php-jwt/issues", | |||
| "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" | |||
| }, | |||
| "time": "2026-04-01T20:38:03+00:00" | |||
| }, | |||
| { | |||
| "name": "guzzlehttp/guzzle", | |||
| "version": "7.10.3", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/guzzle/guzzle.git", | |||
| "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", | |||
| "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-json": "*", | |||
| "guzzlehttp/promises": "^2.3", | |||
| "guzzlehttp/psr7": "^2.8", | |||
| "php": "^7.2.5 || ^8.0", | |||
| "psr/http-client": "^1.0", | |||
| "symfony/deprecation-contracts": "^2.2 || ^3.0" | |||
| }, | |||
| "provide": { | |||
| "psr/http-client-implementation": "1.0" | |||
| }, | |||
| "require-dev": { | |||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||
| "ext-curl": "*", | |||
| "guzzle/client-integration-tests": "3.0.2", | |||
| "guzzlehttp/test-server": "^0.3.2", | |||
| "php-http/message-factory": "^1.1", | |||
| "phpunit/phpunit": "^8.5.52 || ^9.6.34", | |||
| "psr/log": "^1.1 || ^2.0 || ^3.0" | |||
| }, | |||
| "suggest": { | |||
| "ext-curl": "Required for CURL handler support", | |||
| "ext-intl": "Required for Internationalized Domain Name (IDN) support", | |||
| "psr/log": "Required for using the Log middleware" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "bamarni-bin": { | |||
| "bin-links": true, | |||
| "forward-command": false | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "files": [ | |||
| "src/functions_include.php" | |||
| ], | |||
| "psr-4": { | |||
| "GuzzleHttp\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Graham Campbell", | |||
| "email": "hello@gjcampbell.co.uk", | |||
| "homepage": "https://github.com/GrahamCampbell" | |||
| }, | |||
| { | |||
| "name": "Michael Dowling", | |||
| "email": "mtdowling@gmail.com", | |||
| "homepage": "https://github.com/mtdowling" | |||
| }, | |||
| { | |||
| "name": "Jeremy Lindblom", | |||
| "email": "jeremeamia@gmail.com", | |||
| "homepage": "https://github.com/jeremeamia" | |||
| }, | |||
| { | |||
| "name": "George Mponos", | |||
| "email": "gmponos@gmail.com", | |||
| "homepage": "https://github.com/gmponos" | |||
| }, | |||
| { | |||
| "name": "Tobias Nyholm", | |||
| "email": "tobias.nyholm@gmail.com", | |||
| "homepage": "https://github.com/Nyholm" | |||
| }, | |||
| { | |||
| "name": "Márk Sági-Kazár", | |||
| "email": "mark.sagikazar@gmail.com", | |||
| "homepage": "https://github.com/sagikazarmark" | |||
| }, | |||
| { | |||
| "name": "Tobias Schultze", | |||
| "email": "webmaster@tubo-world.de", | |||
| "homepage": "https://github.com/Tobion" | |||
| } | |||
| ], | |||
| "description": "Guzzle is a PHP HTTP client library", | |||
| "keywords": [ | |||
| "client", | |||
| "curl", | |||
| "framework", | |||
| "http", | |||
| "http client", | |||
| "psr-18", | |||
| "psr-7", | |||
| "rest", | |||
| "web service" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/guzzle/guzzle/issues", | |||
| "source": "https://github.com/guzzle/guzzle/tree/7.10.3" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://github.com/GrahamCampbell", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/Nyholm", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", | |||
| "type": "tidelift" | |||
| } | |||
| ], | |||
| "time": "2026-05-20T22:59:19+00:00" | |||
| }, | |||
| { | |||
| "name": "guzzlehttp/promises", | |||
| "version": "2.4.1", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/guzzle/promises.git", | |||
| "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", | |||
| "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.2.5 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||
| "phpunit/phpunit": "^8.5.52 || ^9.6.34" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "bamarni-bin": { | |||
| "bin-links": true, | |||
| "forward-command": false | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "GuzzleHttp\\Promise\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Graham Campbell", | |||
| "email": "hello@gjcampbell.co.uk", | |||
| "homepage": "https://github.com/GrahamCampbell" | |||
| }, | |||
| { | |||
| "name": "Michael Dowling", | |||
| "email": "mtdowling@gmail.com", | |||
| "homepage": "https://github.com/mtdowling" | |||
| }, | |||
| { | |||
| "name": "Tobias Nyholm", | |||
| "email": "tobias.nyholm@gmail.com", | |||
| "homepage": "https://github.com/Nyholm" | |||
| }, | |||
| { | |||
| "name": "Tobias Schultze", | |||
| "email": "webmaster@tubo-world.de", | |||
| "homepage": "https://github.com/Tobion" | |||
| } | |||
| ], | |||
| "description": "Guzzle promises library", | |||
| "keywords": [ | |||
| "promise" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/guzzle/promises/issues", | |||
| "source": "https://github.com/guzzle/promises/tree/2.4.1" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://github.com/GrahamCampbell", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/Nyholm", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", | |||
| "type": "tidelift" | |||
| } | |||
| ], | |||
| "time": "2026-05-20T22:57:30+00:00" | |||
| }, | |||
| { | |||
| "name": "guzzlehttp/psr7", | |||
| "version": "2.10.1", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/guzzle/psr7.git", | |||
| "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", | |||
| "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.2.5 || ^8.0", | |||
| "psr/http-factory": "^1.0", | |||
| "psr/http-message": "^1.1 || ^2.0", | |||
| "ralouphie/getallheaders": "^3.0" | |||
| }, | |||
| "provide": { | |||
| "psr/http-factory-implementation": "1.0", | |||
| "psr/http-message-implementation": "1.0" | |||
| }, | |||
| "require-dev": { | |||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||
| "http-interop/http-factory-tests": "1.1.0", | |||
| "jshttp/mime-db": "1.54.0.1", | |||
| "phpunit/phpunit": "^8.5.52 || ^9.6.34" | |||
| }, | |||
| "suggest": { | |||
| "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "bamarni-bin": { | |||
| "bin-links": true, | |||
| "forward-command": false | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "GuzzleHttp\\Psr7\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Graham Campbell", | |||
| "email": "hello@gjcampbell.co.uk", | |||
| "homepage": "https://github.com/GrahamCampbell" | |||
| }, | |||
| { | |||
| "name": "Michael Dowling", | |||
| "email": "mtdowling@gmail.com", | |||
| "homepage": "https://github.com/mtdowling" | |||
| }, | |||
| { | |||
| "name": "George Mponos", | |||
| "email": "gmponos@gmail.com", | |||
| "homepage": "https://github.com/gmponos" | |||
| }, | |||
| { | |||
| "name": "Tobias Nyholm", | |||
| "email": "tobias.nyholm@gmail.com", | |||
| "homepage": "https://github.com/Nyholm" | |||
| }, | |||
| { | |||
| "name": "Márk Sági-Kazár", | |||
| "email": "mark.sagikazar@gmail.com", | |||
| "homepage": "https://github.com/sagikazarmark" | |||
| }, | |||
| { | |||
| "name": "Tobias Schultze", | |||
| "email": "webmaster@tubo-world.de", | |||
| "homepage": "https://github.com/Tobion" | |||
| }, | |||
| { | |||
| "name": "Márk Sági-Kazár", | |||
| "email": "mark.sagikazar@gmail.com", | |||
| "homepage": "https://sagikazarmark.hu" | |||
| } | |||
| ], | |||
| "description": "PSR-7 message implementation that also provides common utility methods", | |||
| "keywords": [ | |||
| "http", | |||
| "message", | |||
| "psr-7", | |||
| "request", | |||
| "response", | |||
| "stream", | |||
| "uri", | |||
| "url" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/guzzle/psr7/issues", | |||
| "source": "https://github.com/guzzle/psr7/tree/2.10.1" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://github.com/GrahamCampbell", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/Nyholm", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", | |||
| "type": "tidelift" | |||
| } | |||
| ], | |||
| "time": "2026-05-20T09:27:36+00:00" | |||
| }, | |||
| { | |||
| "name": "league/oauth2-client", | |||
| "version": "2.9.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/thephpleague/oauth2-client.git", | |||
| "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", | |||
| "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-json": "*", | |||
| "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", | |||
| "php": "^7.1 || >=8.0.0 <8.6.0" | |||
| }, | |||
| "require-dev": { | |||
| "mockery/mockery": "^1.3.5", | |||
| "php-parallel-lint/php-parallel-lint": "^1.4", | |||
| "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", | |||
| "squizlabs/php_codesniffer": "^3.11" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "League\\OAuth2\\Client\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Alex Bilbie", | |||
| "email": "hello@alexbilbie.com", | |||
| "homepage": "http://www.alexbilbie.com", | |||
| "role": "Developer" | |||
| }, | |||
| { | |||
| "name": "Woody Gilk", | |||
| "homepage": "https://github.com/shadowhand", | |||
| "role": "Contributor" | |||
| } | |||
| ], | |||
| "description": "OAuth 2.0 Client Library", | |||
| "keywords": [ | |||
| "Authentication", | |||
| "SSO", | |||
| "authorization", | |||
| "identity", | |||
| "idp", | |||
| "oauth", | |||
| "oauth2", | |||
| "single sign on" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/thephpleague/oauth2-client/issues", | |||
| "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" | |||
| }, | |||
| "time": "2025-11-25T22:17:17+00:00" | |||
| }, | |||
| { | |||
| "name": "psr/http-client", | |||
| "version": "1.0.3", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/php-fig/http-client.git", | |||
| "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", | |||
| "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.0 || ^8.0", | |||
| "psr/http-message": "^1.0 || ^2.0" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-master": "1.0.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Psr\\Http\\Client\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "PHP-FIG", | |||
| "homepage": "https://www.php-fig.org/" | |||
| } | |||
| ], | |||
| "description": "Common interface for HTTP clients", | |||
| "homepage": "https://github.com/php-fig/http-client", | |||
| "keywords": [ | |||
| "http", | |||
| "http-client", | |||
| "psr", | |||
| "psr-18" | |||
| ], | |||
| "support": { | |||
| "source": "https://github.com/php-fig/http-client" | |||
| }, | |||
| "time": "2023-09-23T14:17:50+00:00" | |||
| }, | |||
| { | |||
| "name": "psr/http-factory", | |||
| "version": "1.1.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/php-fig/http-factory.git", | |||
| "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", | |||
| "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": ">=7.1", | |||
| "psr/http-message": "^1.0 || ^2.0" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-master": "1.0.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Psr\\Http\\Message\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "PHP-FIG", | |||
| "homepage": "https://www.php-fig.org/" | |||
| } | |||
| ], | |||
| "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", | |||
| "keywords": [ | |||
| "factory", | |||
| "http", | |||
| "message", | |||
| "psr", | |||
| "psr-17", | |||
| "psr-7", | |||
| "request", | |||
| "response" | |||
| ], | |||
| "support": { | |||
| "source": "https://github.com/php-fig/http-factory" | |||
| }, | |||
| "time": "2024-04-15T12:06:14+00:00" | |||
| }, | |||
| { | |||
| "name": "psr/http-message", | |||
| "version": "2.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/php-fig/http-message.git", | |||
| "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", | |||
| "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.2 || ^8.0" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-master": "2.0.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Psr\\Http\\Message\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "PHP-FIG", | |||
| "homepage": "https://www.php-fig.org/" | |||
| } | |||
| ], | |||
| "description": "Common interface for HTTP messages", | |||
| "homepage": "https://github.com/php-fig/http-message", | |||
| "keywords": [ | |||
| "http", | |||
| "http-message", | |||
| "psr", | |||
| "psr-7", | |||
| "request", | |||
| "response" | |||
| ], | |||
| "support": { | |||
| "source": "https://github.com/php-fig/http-message/tree/2.0" | |||
| }, | |||
| "time": "2023-04-04T09:54:51+00:00" | |||
| }, | |||
| { | |||
| "name": "ralouphie/getallheaders", | |||
| "version": "3.0.3", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/ralouphie/getallheaders.git", | |||
| "reference": "120b605dfeb996808c31b6477290a714d356e822" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", | |||
| "reference": "120b605dfeb996808c31b6477290a714d356e822", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": ">=5.6" | |||
| }, | |||
| "require-dev": { | |||
| "php-coveralls/php-coveralls": "^2.1", | |||
| "phpunit/phpunit": "^5 || ^6.5" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "files": [ | |||
| "src/getallheaders.php" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Ralph Khattar", | |||
| "email": "ralph.khattar@gmail.com" | |||
| } | |||
| ], | |||
| "description": "A polyfill for getallheaders.", | |||
| "support": { | |||
| "issues": "https://github.com/ralouphie/getallheaders/issues", | |||
| "source": "https://github.com/ralouphie/getallheaders/tree/develop" | |||
| }, | |||
| "time": "2019-03-08T08:55:37+00:00" | |||
| }, | |||
| { | |||
| "name": "stevenmaguire/oauth2-keycloak", | |||
| "version": "6.1.1", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/stevenmaguire/oauth2-keycloak.git", | |||
| "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/31bb3b1fa15b444212ed43facc898fafc7c2707a", | |||
| "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "firebase/php-jwt": "^7.0", | |||
| "league/oauth2-client": "^2.8", | |||
| "php": "^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "mockery/mockery": "^1.6", | |||
| "phpstan/phpstan": "^1.12", | |||
| "phpunit/phpunit": "~9.6.4", | |||
| "squizlabs/php_codesniffer": "~3.7.0" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-master": "1.0.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Stevenmaguire\\OAuth2\\Client\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Steven Maguire", | |||
| "email": "stevenmaguire@gmail.com", | |||
| "homepage": "https://github.com/stevenmaguire" | |||
| } | |||
| ], | |||
| "description": "Keycloak OAuth 2.0 Client Provider for The PHP League OAuth2-Client", | |||
| "keywords": [ | |||
| "authorisation", | |||
| "authorization", | |||
| "client", | |||
| "keycloak", | |||
| "oauth", | |||
| "oauth2" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/stevenmaguire/oauth2-keycloak/issues", | |||
| "source": "https://github.com/stevenmaguire/oauth2-keycloak/tree/6.1.1" | |||
| }, | |||
| "time": "2026-03-30T07:32:03+00:00" | |||
| }, | |||
| { | |||
| "name": "symfony/deprecation-contracts", | |||
| "version": "v3.7.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/symfony/deprecation-contracts.git", | |||
| "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", | |||
| "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": ">=8.1" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "thanks": { | |||
| "url": "https://github.com/symfony/contracts", | |||
| "name": "symfony/contracts" | |||
| }, | |||
| "branch-alias": { | |||
| "dev-main": "3.7-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "files": [ | |||
| "function.php" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Nicolas Grekas", | |||
| "email": "p@tchwork.com" | |||
| }, | |||
| { | |||
| "name": "Symfony Community", | |||
| "homepage": "https://symfony.com/contributors" | |||
| } | |||
| ], | |||
| "description": "A generic function and convention to trigger deprecation notices", | |||
| "homepage": "https://symfony.com", | |||
| "support": { | |||
| "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://symfony.com/sponsor", | |||
| "type": "custom" | |||
| }, | |||
| { | |||
| "url": "https://github.com/fabpot", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/nicolas-grekas", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", | |||
| "type": "tidelift" | |||
| } | |||
| ], | |||
| "time": "2026-04-13T15:52:40+00:00" | |||
| } | |||
| ], | |||
| "packages-dev": [], | |||
| "aliases": [], | |||
| "minimum-stability": "stable", | |||
| "stability-flags": {}, | |||
| "prefer-stable": false, | |||
| "prefer-lowest": false, | |||
| "platform": {}, | |||
| "platform-dev": {}, | |||
| "plugin-api-version": "2.9.0" | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| return [ | |||
| 'keycloak' => [ | |||
| 'base_url' => getenv('KEYCLOAK_BASE_URL') ?: '', | |||
| 'realm' => getenv('KEYCLOAK_REALM') ?: '', | |||
| 'client_id' => getenv('KEYCLOAK_CLIENT_ID') ?: '', | |||
| 'client_secret' => getenv('KEYCLOAK_CLIENT_SECRET') ?: '', | |||
| 'redirect_uri' => getenv('KEYCLOAK_REDIRECT_URI') ?: '', | |||
| 'post_logout_redirect_uri' => getenv('APP_URL') ?: 'http://localhost:8000', | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,11 @@ | |||
| <?php | |||
| return [ | |||
| 'dsn' => 'sqlite:' . __DIR__ . '/../database/app.sqlite', | |||
| 'username' => null, | |||
| 'password' => null, | |||
| 'options' => [ | |||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||
| ], | |||
| ]; | |||
| @@ -0,0 +1,6 @@ | |||
| <?php | |||
| return [ | |||
| 'views_path' => __DIR__ . '/../app/Views', | |||
| 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', | |||
| ]; | |||
| @@ -0,0 +1,154 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| use Exception; | |||
| use ReflectionClass; | |||
| use ReflectionFunction; | |||
| use ReflectionFunctionAbstract; | |||
| use ReflectionMethod; | |||
| class App | |||
| { | |||
| protected array $bindings = []; | |||
| protected array $instances = []; | |||
| public function bind(string $name, mixed $value): void | |||
| { | |||
| $this->bindings[$name] = $value; | |||
| } | |||
| public function instance(string $name, mixed $value): void | |||
| { | |||
| $this->instances[$name] = $value; | |||
| } | |||
| public function make(string $className): object | |||
| { | |||
| if (isset($this->bindings[$className])) { | |||
| $binding = $this->bindings[$className]; | |||
| if (is_string($binding) && is_a($binding, $className, true)) { | |||
| return $this->instantiate($binding); | |||
| } | |||
| return $binding; | |||
| } | |||
| if (isset($this->instances[$className])) { | |||
| return $this->instances[$className]; | |||
| } | |||
| return $this->instantiate($className); | |||
| } | |||
| public function clear(): void | |||
| { | |||
| $this->bindings = []; | |||
| $this->instances = []; | |||
| } | |||
| public function get(string $name): mixed | |||
| { | |||
| return $this->instances[$name] ?? $this->bindings[$name] ?? null; | |||
| } | |||
| public function call(callable|array|string $handler, array $parameters = []): mixed | |||
| { | |||
| if (is_array($handler)) { | |||
| return $this->callMethod($handler, $parameters); | |||
| } | |||
| if (is_callable($handler)) { | |||
| return $this->callFunction($handler, $parameters); | |||
| } | |||
| throw new Exception('Invalid handler.'); | |||
| } | |||
| protected function callFunction(callable $handler, array $parameters): mixed | |||
| { | |||
| $reflection = new ReflectionFunction($handler); | |||
| return $reflection->invokeArgs($this->resolveArgs($reflection, $parameters)); | |||
| } | |||
| protected function callMethod(array $handler, array $parameters): mixed | |||
| { | |||
| [$class, $method] = $handler; | |||
| if (is_string($class)) { | |||
| $class = new $class(); | |||
| } | |||
| $reflection = new ReflectionMethod($class, $method); | |||
| return $reflection->invokeArgs($class, $this->resolveArgs($reflection, $parameters)); | |||
| } | |||
| protected function resolveArgs(\ReflectionFunctionAbstract $reflection, array $parameters): array | |||
| { | |||
| $args = []; | |||
| foreach ($reflection->getParameters() as $param) { | |||
| $name = $param->getName(); | |||
| if (array_key_exists($name, $parameters)) { | |||
| $args[] = $parameters[$name]; | |||
| continue; | |||
| } | |||
| $type = $param->getType(); | |||
| if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { | |||
| $typeName = $type->getName(); | |||
| if (array_key_exists($typeName, $this->instances)) { | |||
| $args[] = $this->instances[$typeName]; | |||
| continue; | |||
| } | |||
| if (array_key_exists($typeName, $this->bindings)) { | |||
| $binding = $this->bindings[$typeName]; | |||
| if (is_string($binding) && is_a($binding, $typeName, true)) { | |||
| $args[] = $this->instantiate($binding); | |||
| continue; | |||
| } | |||
| $args[] = $binding; | |||
| continue; | |||
| } | |||
| } | |||
| $args[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; | |||
| } | |||
| return $args; | |||
| } | |||
| /** | |||
| * @param class-string $className | |||
| */ | |||
| protected function instantiate(string $className): object | |||
| { | |||
| $reflection = new ReflectionClass($className); | |||
| if (!$reflection->isInstantiable()) { | |||
| throw new Exception("Cannot instantiate {$className}: class is not instantiable."); | |||
| } | |||
| $constructor = $reflection->getConstructor(); | |||
| if ($constructor === null) { | |||
| return new $className(); | |||
| } | |||
| $args = $this->resolveArgs($constructor, []); | |||
| return $reflection->newInstanceArgs($args); | |||
| } | |||
| } | |||
| @@ -0,0 +1,37 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| abstract class Controller | |||
| { | |||
| protected function view(string $view, array $data = []): Response | |||
| { | |||
| return View::render($view, $data); | |||
| } | |||
| protected function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||
| { | |||
| return View::fragment($view, $data, $status, $headers); | |||
| } | |||
| protected function redirect(string $url): Response | |||
| { | |||
| return Response::redirect($url); | |||
| } | |||
| protected function json(array $data): Response | |||
| { | |||
| return Response::json($data); | |||
| } | |||
| protected function requirePost(Request $request): ?Response | |||
| { | |||
| if ($request->method() !== 'POST') { | |||
| return new Response('Method Not Allowed.', 405); | |||
| } | |||
| return null; | |||
| } | |||
| } | |||
| @@ -0,0 +1,69 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| use PDO; | |||
| class Database | |||
| { | |||
| protected PDO $pdo; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->pdo = new PDO( | |||
| $config['dsn'], | |||
| $config['username'] ?? null, | |||
| $config['password'] ?? null, | |||
| $config['options'] ?? [] | |||
| ); | |||
| } | |||
| public function pdo(): PDO | |||
| { | |||
| return $this->pdo; | |||
| } | |||
| public function query(string $sql, array $parameters = []): array | |||
| { | |||
| $statement = $this->pdo->prepare($sql); | |||
| $statement->execute($parameters); | |||
| return $statement->fetchAll(PDO::FETCH_ASSOC); | |||
| } | |||
| public function first(string $sql, array $parameters = []): ?array | |||
| { | |||
| $rows = $this->query($sql, $parameters); | |||
| return $rows[0] ?? null; | |||
| } | |||
| public function execute(string $sql, array $parameters = []): bool | |||
| { | |||
| $statement = $this->pdo->prepare($sql); | |||
| return $statement->execute($parameters); | |||
| } | |||
| public function lastInsertId(): string | |||
| { | |||
| return $this->pdo->lastInsertId(); | |||
| } | |||
| public function transaction(callable $fn): mixed | |||
| { | |||
| $this->pdo->beginTransaction(); | |||
| try { | |||
| $result = $fn($this); | |||
| $this->pdo->commit(); | |||
| return $result; | |||
| } catch (\Throwable $e) { | |||
| $this->pdo->rollBack(); | |||
| throw $e; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,61 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| use Throwable; | |||
| class Dispatcher | |||
| { | |||
| protected Router $router; | |||
| protected App $app; | |||
| protected bool $debug; | |||
| public function __construct(Router $router, App $app, bool $debug = false) | |||
| { | |||
| $this->router = $router; | |||
| $this->app = $app; | |||
| $this->debug = $debug; | |||
| } | |||
| public function dispatch(Request $request): Response | |||
| { | |||
| Request::setCurrent($request); | |||
| try { | |||
| $route = $this->router->match($request->method(), $request->path()); | |||
| if (!$route) { | |||
| return Response::notFound('Page not found.'); | |||
| } | |||
| $result = $route->dispatch($this->app); | |||
| return $this->normalizeResponse($result); | |||
| } catch (Throwable $e) { | |||
| if (!$this->debug) { | |||
| error_log($e->getMessage()); | |||
| } | |||
| $message = $this->debug ? $e->getMessage() : 'An unexpected error occurred.'; | |||
| return Response::serverError($message); | |||
| } finally { | |||
| Request::clearCurrent(); | |||
| } | |||
| } | |||
| protected function normalizeResponse(mixed $result): Response | |||
| { | |||
| if ($result instanceof Response) { | |||
| return $result; | |||
| } | |||
| if (is_array($result)) { | |||
| return Response::json($result); | |||
| } | |||
| return new Response((string) $result); | |||
| } | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| abstract class Migration | |||
| { | |||
| abstract public function up(Database $database): void; | |||
| abstract public function down(Database $database): void; | |||
| } | |||
| @@ -0,0 +1,233 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class MigrationManager | |||
| { | |||
| protected Database $database; | |||
| protected string $path; | |||
| public function __construct(Database $database, string $path) | |||
| { | |||
| $this->database = $database; | |||
| $this->path = rtrim($path, '/'); | |||
| } | |||
| public function ensureTable(): void | |||
| { | |||
| $this->database->execute( | |||
| 'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)' | |||
| ); | |||
| $this->database->execute( | |||
| 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' | |||
| ); | |||
| } | |||
| public function status(): array | |||
| { | |||
| $this->ensureTable(); | |||
| $ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC'); | |||
| $ranByName = []; | |||
| foreach ($ran as $row) { | |||
| $ranByName[$row['migration']] = $row['ran_at']; | |||
| } | |||
| $files = $this->migrationFiles(); | |||
| $status = []; | |||
| foreach ($files as $file) { | |||
| $name = basename($file); | |||
| $status[] = [ | |||
| 'migration' => $name, | |||
| 'ran' => array_key_exists($name, $ranByName), | |||
| 'ran_at' => $ranByName[$name] ?? null, | |||
| ]; | |||
| } | |||
| return $status; | |||
| } | |||
| public function runPending(): array | |||
| { | |||
| $this->ensureTable(); | |||
| $ran = $this->database->query('SELECT migration FROM migrations'); | |||
| $ranNames = array_column($ran, 'migration'); | |||
| $files = $this->migrationFiles(); | |||
| $ranMigrations = []; | |||
| foreach ($files as $file) { | |||
| $name = basename($file); | |||
| if (in_array($name, $ranNames, true)) { | |||
| continue; | |||
| } | |||
| $migration = $this->loadMigration($file); | |||
| $this->database->transaction(function (Database $db) use ($migration, $name, &$ranMigrations): void { | |||
| $migration->up($db); | |||
| if ($db->first('SELECT id FROM migrations WHERE migration = :migration', ['migration' => $name]) === null) { | |||
| $db->execute('INSERT INTO migrations (migration) VALUES (:migration)', ['migration' => $name]); | |||
| } | |||
| $ranMigrations[] = $name; | |||
| }); | |||
| } | |||
| return $ranMigrations; | |||
| } | |||
| public function rollback(int $steps = 1): array | |||
| { | |||
| $this->ensureTable(); | |||
| $steps = max(1, $steps); | |||
| $applied = $this->database->query( | |||
| 'SELECT id, migration FROM migrations ORDER BY id DESC LIMIT :steps', | |||
| ['steps' => $steps] | |||
| ); | |||
| $rolledBack = []; | |||
| foreach ($applied as $row) { | |||
| $file = $this->path . '/' . $row['migration']; | |||
| if (!file_exists($file)) { | |||
| throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}"); | |||
| } | |||
| $migration = $this->loadMigration($file); | |||
| $this->database->transaction(function (Database $db) use ($migration, $row, &$rolledBack): void { | |||
| $migration->down($db); | |||
| $db->execute('DELETE FROM migrations WHERE id = :id', ['id' => $row['id']]); | |||
| $rolledBack[] = $row['migration']; | |||
| }); | |||
| } | |||
| return $rolledBack; | |||
| } | |||
| public function fresh(): array | |||
| { | |||
| $this->ensureTable(); | |||
| $files = array_reverse($this->migrationFiles()); | |||
| $rolledBack = []; | |||
| foreach ($files as $file) { | |||
| $migration = $this->loadMigration($file); | |||
| $name = basename($file); | |||
| $this->database->transaction(function (Database $db) use ($migration, $name, &$rolledBack): void { | |||
| $migration->down($db); | |||
| $rolledBack[] = $name; | |||
| }); | |||
| } | |||
| $this->database->execute('DELETE FROM migrations'); | |||
| $ran = $this->runPending(); | |||
| return [ | |||
| 'rolled_back' => $rolledBack, | |||
| 'migrated' => $ran, | |||
| ]; | |||
| } | |||
| public function make(string $name): string | |||
| { | |||
| $slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_'); | |||
| if ($slug === '') { | |||
| throw new \InvalidArgumentException('Migration name must contain letters or numbers.'); | |||
| } | |||
| if (!is_dir($this->path)) { | |||
| mkdir($this->path, 0777, true); | |||
| } | |||
| $timestamp = date('Ymd_His'); | |||
| $filename = $timestamp . '_' . $slug . '.php'; | |||
| $path = $this->path . '/' . $filename; | |||
| if (file_exists($path)) { | |||
| throw new \RuntimeException("Migration already exists: {$filename}"); | |||
| } | |||
| $template = <<<PHP | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database \$database): void | |||
| { | |||
| // Write the forward migration here. | |||
| } | |||
| public function down(Database \$database): void | |||
| { | |||
| // Write the rollback migration here. | |||
| } | |||
| }; | |||
| PHP; | |||
| file_put_contents($path, $template . PHP_EOL); | |||
| return $path; | |||
| } | |||
| private function migrationFiles(): array | |||
| { | |||
| $files = glob($this->path . '/*.php') ?: []; | |||
| sort($files); | |||
| return $files; | |||
| } | |||
| private function loadMigration(string $file): Migration | |||
| { | |||
| $migration = require $file; | |||
| if ($migration instanceof Migration) { | |||
| return $migration; | |||
| } | |||
| if (is_callable($migration)) { | |||
| return new class ($migration, basename($file)) extends Migration | |||
| { | |||
| private $callback; | |||
| private string $name; | |||
| public function __construct(callable $callback, string $name) | |||
| { | |||
| $this->callback = $callback; | |||
| $this->name = $name; | |||
| } | |||
| public function up(Database $database): void | |||
| { | |||
| ($this->callback)($database); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method."); | |||
| } | |||
| }; | |||
| } | |||
| throw new \RuntimeException('Migration files must return a Migration instance.'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,38 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| abstract class Repository | |||
| { | |||
| protected Database $database; | |||
| protected string $table; | |||
| protected string $primaryKey = 'id'; | |||
| public function __construct(Database $database) | |||
| { | |||
| $this->database = $database; | |||
| } | |||
| public function find(int|string $id): ?array | |||
| { | |||
| return $this->database->first( | |||
| "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||
| ['id' => $id] | |||
| ); | |||
| } | |||
| public function all(): array | |||
| { | |||
| return $this->database->query("SELECT * FROM {$this->table}"); | |||
| } | |||
| public function delete(int|string $id): bool | |||
| { | |||
| return $this->database->execute( | |||
| "DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||
| ['id' => $id] | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,80 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class Request | |||
| { | |||
| protected static ?self $current = null; | |||
| protected string $method; | |||
| protected string $uri; | |||
| protected array $get; | |||
| protected array $post; | |||
| protected array $server; | |||
| public function __construct(array $get, array $post, array $server) | |||
| { | |||
| $this->get = $get; | |||
| $this->post = $post; | |||
| $this->server = $server; | |||
| $this->method = $server['REQUEST_METHOD'] ?? 'GET'; | |||
| $this->uri = $server['REQUEST_URI'] ?? '/'; | |||
| } | |||
| public static function capture(): self | |||
| { | |||
| if (self::$current instanceof self) { | |||
| return self::$current; | |||
| } | |||
| return new self($_GET, $_POST, $_SERVER); | |||
| } | |||
| public static function setCurrent(self $request): void | |||
| { | |||
| self::$current = $request; | |||
| } | |||
| public static function clearCurrent(): void | |||
| { | |||
| self::$current = null; | |||
| } | |||
| public function method(): string | |||
| { | |||
| $method = strtoupper($this->method); | |||
| if ($method === 'POST') { | |||
| $override = strtoupper((string) ($this->post['_method'] ?? $this->server['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? '')); | |||
| if (in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) { | |||
| return $override; | |||
| } | |||
| } | |||
| return $method; | |||
| } | |||
| public function path(): string | |||
| { | |||
| $path = parse_url($this->uri, PHP_URL_PATH); | |||
| return $path ?: '/'; | |||
| } | |||
| public function input(string $key, mixed $default = null): mixed | |||
| { | |||
| return $this->post[$key] ?? $this->get[$key] ?? $default; | |||
| } | |||
| public function server(string $key, mixed $default = null): mixed | |||
| { | |||
| return $this->server[$key] ?? $default; | |||
| } | |||
| public function all(): array | |||
| { | |||
| return array_merge($this->get, $this->post); | |||
| } | |||
| } | |||
| @@ -0,0 +1,64 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class Response | |||
| { | |||
| protected string $content; | |||
| protected int $status; | |||
| protected array $headers; | |||
| public function __construct(string $content = '', int $status = 200, array $headers = []) | |||
| { | |||
| $this->content = $content; | |||
| $this->status = $status; | |||
| $this->headers = $headers; | |||
| } | |||
| public static function json(array $data, int $status = 200): self | |||
| { | |||
| return new self( | |||
| json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), | |||
| $status, | |||
| ['Content-Type' => 'application/json'] | |||
| ); | |||
| } | |||
| public static function redirect(string $url): self | |||
| { | |||
| return new self('', 302, ['Location' => $url]); | |||
| } | |||
| public static function notFound(string $message = 'Not found'): self | |||
| { | |||
| return new self($message, 404); | |||
| } | |||
| public static function serverError(string $message = 'Server error'): self | |||
| { | |||
| return new self($message, 500); | |||
| } | |||
| public function send(): void | |||
| { | |||
| http_response_code($this->status); | |||
| foreach ($this->headers as $name => $value) { | |||
| header($name . ': ' . $value); | |||
| } | |||
| echo $this->content; | |||
| } | |||
| public function content(): string | |||
| { | |||
| return $this->content; | |||
| } | |||
| public function status(): int | |||
| { | |||
| return $this->status; | |||
| } | |||
| } | |||
| @@ -0,0 +1,52 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class Route | |||
| { | |||
| protected string $method; | |||
| protected string $path; | |||
| protected mixed $handler; | |||
| protected array $parameters = []; | |||
| protected string $compiledPattern; | |||
| protected array $parameterNames = []; | |||
| public function __construct(string $method, string $path, mixed $handler) | |||
| { | |||
| $this->method = strtoupper($method); | |||
| $this->path = $path; | |||
| $this->handler = $handler; | |||
| preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $path, $names); | |||
| $this->parameterNames = $names[1]; | |||
| $compiled = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $path); | |||
| $this->compiledPattern = '#^' . $compiled . '$#'; | |||
| } | |||
| public function matches(string $method, string $path): bool | |||
| { | |||
| if (strtoupper($method) !== $this->method) { | |||
| return false; | |||
| } | |||
| if (!preg_match($this->compiledPattern, $path, $matches)) { | |||
| return false; | |||
| } | |||
| array_shift($matches); | |||
| $this->parameters = []; | |||
| foreach ($this->parameterNames as $index => $name) { | |||
| $this->parameters[$name] = $matches[$index] ?? null; | |||
| } | |||
| return true; | |||
| } | |||
| public function dispatch(App $app): mixed | |||
| { | |||
| return $app->call($this->handler, $this->parameters); | |||
| } | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class Router | |||
| { | |||
| protected array $routes = []; | |||
| public function get(string $path, callable|array|string $handler): Route | |||
| { | |||
| return $this->add('GET', $path, $handler); | |||
| } | |||
| public function post(string $path, callable|array|string $handler): Route | |||
| { | |||
| return $this->add('POST', $path, $handler); | |||
| } | |||
| public function put(string $path, callable|array|string $handler): Route | |||
| { | |||
| return $this->add('PUT', $path, $handler); | |||
| } | |||
| public function patch(string $path, callable|array|string $handler): Route | |||
| { | |||
| return $this->add('PATCH', $path, $handler); | |||
| } | |||
| public function delete(string $path, callable|array|string $handler): Route | |||
| { | |||
| return $this->add('DELETE', $path, $handler); | |||
| } | |||
| public function add(string $method, string $path, callable|array|string $handler): Route | |||
| { | |||
| $route = new Route($method, $path, $handler); | |||
| $this->routes[] = $route; | |||
| return $route; | |||
| } | |||
| public function match(string $method, string $path): ?Route | |||
| { | |||
| foreach ($this->routes as $route) { | |||
| if ($route->matches($method, $path)) { | |||
| return $route; | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| } | |||
| @@ -0,0 +1,114 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class Validator | |||
| { | |||
| protected array $errors = []; | |||
| public function required(string $field, mixed $value, string $message = ''): self | |||
| { | |||
| if ($value === null || trim((string) $value) === '') { | |||
| $this->errors[$field][] = $message ?: "{$field} is required."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function maxLength(string $field, mixed $value, int $max, string $message = ''): self | |||
| { | |||
| if (strlen((string) $value) > $max) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be {$max} characters or fewer."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function numeric(string $field, mixed $value, string $message = ''): self | |||
| { | |||
| if (!is_numeric($value)) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be numeric."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function email(string $field, mixed $value, string $message = ''): self | |||
| { | |||
| if ((string) $value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) === false) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be a valid email address."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function date(string $field, mixed $value, string $format = 'Y-m-d', string $message = ''): self | |||
| { | |||
| $str = (string) $value; | |||
| if ($str === '') { | |||
| return $this; | |||
| } | |||
| $parsed = \DateTimeImmutable::createFromFormat($format, $str); | |||
| if ($parsed === false || $parsed->format($format) !== $str) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be a valid date ({$format})."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function minLength(string $field, mixed $value, int $min, string $message = ''): self | |||
| { | |||
| if (strlen((string) $value) < $min) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be at least {$min} characters."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function min(string $field, mixed $value, int|float $min, string $message = ''): self | |||
| { | |||
| if (!is_numeric($value) || (float) $value < $min) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be at least {$min}."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function max(string $field, mixed $value, int|float $max, string $message = ''): self | |||
| { | |||
| if (!is_numeric($value) || (float) $value > $max) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be no more than {$max}."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function in(string $field, mixed $value, array $allowed, string $message = ''): self | |||
| { | |||
| if (!in_array($value, $allowed, true)) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be one of: " . implode(', ', $allowed) . '.'; | |||
| } | |||
| return $this; | |||
| } | |||
| public function passes(): bool | |||
| { | |||
| return empty($this->errors); | |||
| } | |||
| public function fails(): bool | |||
| { | |||
| return !$this->passes(); | |||
| } | |||
| public function errors(): array | |||
| { | |||
| return $this->errors; | |||
| } | |||
| } | |||
| @@ -0,0 +1,79 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class View | |||
| { | |||
| public static function render(string $view, array $data = []): Response | |||
| { | |||
| $content = self::renderContent($view, $data); | |||
| $layoutPath = self::config()['layout_path']; | |||
| if (!file_exists($layoutPath)) { | |||
| return new Response($content); | |||
| } | |||
| $pageTitle = self::resolvePageTitle($data); | |||
| extract($data, EXTR_SKIP); | |||
| ob_start(); | |||
| require $layoutPath; | |||
| $content = ob_get_clean(); | |||
| return new Response($content); | |||
| } | |||
| public static function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||
| { | |||
| return new Response(self::renderContent($view, $data), $status, $headers); | |||
| } | |||
| private static function renderContent(string $view, array $data): string | |||
| { | |||
| $path = self::config()['views_path'] . '/' . str_replace('.', '/', $view) . '.php'; | |||
| if (!file_exists($path)) { | |||
| throw new \Exception("View not found: {$view}"); | |||
| } | |||
| extract($data, EXTR_SKIP); | |||
| ob_start(); | |||
| require $path; | |||
| return (string) ob_get_clean(); | |||
| } | |||
| private static function config(): array | |||
| { | |||
| static $config = null; | |||
| if ($config === null) { | |||
| $config = require __DIR__ . '/../config/view.php'; | |||
| } | |||
| return $config; | |||
| } | |||
| private static function resolvePageTitle(array $data): string | |||
| { | |||
| if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') { | |||
| return $data['pageTitle']; | |||
| } | |||
| if ( | |||
| isset($data['model']) && | |||
| is_object($data['model']) && | |||
| property_exists($data['model'], 'title') && | |||
| is_string($data['model']->title) && | |||
| trim($data['model']->title) !== '' | |||
| ) { | |||
| return $data['model']->title; | |||
| } | |||
| return 'MindVisionCode PHP'; | |||
| } | |||
| } | |||
| @@ -0,0 +1,134 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\App; | |||
| use Core\Database; | |||
| use Core\MigrationManager; | |||
| use Core\Response; | |||
| use Core\View; | |||
| function app(): App | |||
| { | |||
| static $app = null; | |||
| if ($app === null) { | |||
| $app = new App(); | |||
| } | |||
| return $app; | |||
| } | |||
| function view(string $view, array $data = []): Response | |||
| { | |||
| return View::render($view, $data); | |||
| } | |||
| function redirect(string $url): Response | |||
| { | |||
| return Response::redirect($url); | |||
| } | |||
| function database(): Database | |||
| { | |||
| static $database = null; | |||
| if ($database === null) { | |||
| /** @var array<string, mixed> $config */ | |||
| $config = require __DIR__ . '/../config/database.php'; | |||
| prepareSqliteDatabase($config['dsn'] ?? ''); | |||
| $database = new Database($config); | |||
| } | |||
| return $database; | |||
| } | |||
| function migration_manager(): MigrationManager | |||
| { | |||
| static $migrationManager = null; | |||
| if ($migrationManager === null) { | |||
| $migrationManager = new MigrationManager(database(), __DIR__ . '/../database/migrations'); | |||
| } | |||
| return $migrationManager; | |||
| } | |||
| function ensureSessionStarted(): void | |||
| { | |||
| if (session_status() === PHP_SESSION_NONE) { | |||
| session_start(); | |||
| } | |||
| } | |||
| function prepareSqliteDatabase(string $dsn): void | |||
| { | |||
| if (!str_starts_with($dsn, 'sqlite:')) { | |||
| return; | |||
| } | |||
| $path = substr($dsn, 7); | |||
| if ($path === false || $path === '') { | |||
| return; | |||
| } | |||
| $directory = dirname($path); | |||
| if (!is_dir($directory)) { | |||
| mkdir($directory, 0777, true); | |||
| } | |||
| if (!is_writable($directory)) { | |||
| @chmod($directory, 0777); | |||
| } | |||
| if (!file_exists($path)) { | |||
| touch($path); | |||
| } | |||
| if (!is_writable($path)) { | |||
| @chmod($path, 0666); | |||
| } | |||
| } | |||
| function e(?string $value): string | |||
| { | |||
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||
| } | |||
| function asset(string $path): string | |||
| { | |||
| return '/' . ltrim($path, '/'); | |||
| } | |||
| function csrf_token(): string | |||
| { | |||
| ensureSessionStarted(); | |||
| if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) { | |||
| $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); | |||
| } | |||
| return $_SESSION['_csrf_token']; | |||
| } | |||
| function csrf_field(): string | |||
| { | |||
| return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">'; | |||
| } | |||
| function verify_csrf_token(?string $token): bool | |||
| { | |||
| ensureSessionStarted(); | |||
| if (!is_string($token) || $token === '') { | |||
| return false; | |||
| } | |||
| $sessionToken = $_SESSION['_csrf_token'] ?? null; | |||
| return is_string($sessionToken) && hash_equals($sessionToken, $token); | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute( | |||
| 'CREATE TABLE IF NOT EXISTS employees ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| first_name VARCHAR(100) NOT NULL, | |||
| last_name VARCHAR(100) NOT NULL, | |||
| email VARCHAR(255) NOT NULL UNIQUE, | |||
| department VARCHAR(100) NOT NULL, | |||
| job_title VARCHAR(150) NOT NULL, | |||
| start_date DATE NOT NULL, | |||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |||
| )' | |||
| ); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS employees'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,33 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute( | |||
| 'CREATE TABLE IF NOT EXISTS boards ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| name VARCHAR(255) NOT NULL, | |||
| slug VARCHAR(255) NOT NULL UNIQUE, | |||
| import_from_printstream INTEGER NOT NULL DEFAULT 0, | |||
| printstream_job_name TEXT, | |||
| created_at DATETIME, | |||
| created_by VARCHAR(255), | |||
| updated_at DATETIME, | |||
| updated_by VARCHAR(255) | |||
| )' | |||
| ); | |||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_boards_slug ON boards (slug)'); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS boards'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,33 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute( | |||
| 'CREATE TABLE IF NOT EXISTS board_columns ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| board_id INTEGER NOT NULL, | |||
| name VARCHAR(255) NOT NULL, | |||
| position INTEGER NOT NULL DEFAULT 0, | |||
| created_at DATETIME, | |||
| created_by VARCHAR(255), | |||
| updated_at DATETIME, | |||
| updated_by VARCHAR(255), | |||
| FOREIGN KEY (board_id) REFERENCES boards(id) | |||
| )' | |||
| ); | |||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_board_columns_board_id ON board_columns (board_id)'); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS board_columns'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,33 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute( | |||
| 'CREATE TABLE IF NOT EXISTS swim_lanes ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| board_id INTEGER NOT NULL, | |||
| name VARCHAR(255) NOT NULL, | |||
| position INTEGER NOT NULL DEFAULT 0, | |||
| created_at DATETIME, | |||
| created_by VARCHAR(255), | |||
| updated_at DATETIME, | |||
| updated_by VARCHAR(255), | |||
| FOREIGN KEY (board_id) REFERENCES boards(id) | |||
| )' | |||
| ); | |||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_swim_lanes_board_id ON swim_lanes (board_id)'); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS swim_lanes'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,45 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute( | |||
| 'CREATE TABLE IF NOT EXISTS cards ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| board_id INTEGER NOT NULL, | |||
| column_id INTEGER NOT NULL, | |||
| swim_lane_id INTEGER NOT NULL, | |||
| job_number VARCHAR(255), | |||
| job_name VARCHAR(255), | |||
| customer_name VARCHAR(255), | |||
| delivery_date DATE, | |||
| quantity VARCHAR(50), | |||
| notes TEXT, | |||
| full_note TEXT, | |||
| position INTEGER NOT NULL DEFAULT 0, | |||
| created_at DATETIME, | |||
| created_by VARCHAR(255), | |||
| updated_at DATETIME, | |||
| updated_by VARCHAR(255), | |||
| FOREIGN KEY (board_id) REFERENCES boards(id), | |||
| FOREIGN KEY (column_id) REFERENCES board_columns(id), | |||
| FOREIGN KEY (swim_lane_id) REFERENCES swim_lanes(id) | |||
| )' | |||
| ); | |||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_board_id ON cards (board_id)'); | |||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_column_id ON cards (column_id)'); | |||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_swim_lane_id ON cards (swim_lane_id)'); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS cards'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,107 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||
| function seed_employees(int $targetTotal = 1000, bool $resetExisting = false): void | |||
| { | |||
| $targetTotal = max(1, $targetTotal); | |||
| $migrationManager = migration_manager(); | |||
| $migrationManager->runPending(); | |||
| $database = database(); | |||
| if ($resetExisting) { | |||
| $database->execute('DELETE FROM employees'); | |||
| } | |||
| $currentTotal = (int) (database()->first('SELECT COUNT(*) AS total FROM employees')['total'] ?? 0); | |||
| if ($currentTotal >= $targetTotal) { | |||
| echo "Employee table already has {$currentTotal} records." . PHP_EOL; | |||
| return; | |||
| } | |||
| $firstNames = [ | |||
| 'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Mason', 'Sophia', 'Ethan', 'Isabella', 'Lucas', | |||
| 'Mia', 'Amelia', 'James', 'Harper', 'Benjamin', 'Ella', 'Henry', 'Evelyn', 'Jack', 'Abigail', | |||
| 'Alexander', 'Emily', 'Michael', 'Charlotte', 'Daniel', 'Grace', 'Elijah', 'Scarlett', 'William', 'Chloe', | |||
| 'Matthew', 'Victoria', 'Samuel', 'Lily', 'David', 'Aria', 'Joseph', 'Zoey', 'Carter', 'Hannah', | |||
| 'Owen', 'Addison', 'Wyatt', 'Natalie', 'John', 'Aubrey', 'Luke', 'Brooklyn', 'Gabriel', 'Layla', | |||
| 'Anthony', 'Zoe', 'Isaac', 'Penelope', 'Dylan', 'Riley', 'Grayson', 'Nora', 'Levi', 'Lillian', | |||
| 'Julian', 'Eleanor', 'Christopher', 'Stella', 'Joshua', 'Savannah', 'Andrew', 'Audrey', 'Nathan', 'Claire', | |||
| 'Thomas', 'Skylar', 'Caleb', 'Lucy', 'Ryan', 'Paisley', 'Christian', 'Everly', 'Hunter', 'Anna', | |||
| 'Jonathan', 'Caroline', 'Aaron', 'Nova', 'Charles', 'Genesis', 'Connor', 'Kennedy', 'Eli', 'Samantha', | |||
| 'Landon', 'Maya', 'Adrian', 'Willow', 'Nicholas', 'Kinsley', 'Jeremiah', 'Naomi', 'Easton', 'Ariana', | |||
| ]; | |||
| $lastNames = [ | |||
| 'Carter', 'Brooks', 'Hayes', 'Parker', 'Turner', 'Sullivan', 'Reed', 'Ward', 'Price', 'Foster', | |||
| 'Powell', 'Bennett', 'Coleman', 'Russell', 'Long', 'Perry', 'Morgan', 'Peterson', 'Cooper', 'Bailey', | |||
| 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', | |||
| 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', | |||
| 'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson', | |||
| 'Walker', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen', 'Hill', 'Flores', | |||
| 'Green', 'Adams', 'Nelson', 'Baker', 'Hall', 'Rivera', 'Campbell', 'Mitchell', 'Roberts', 'Gomez', | |||
| 'Phillips', 'Evans', 'Edwards', 'Collins', 'Stewart', 'Morris', 'Rogers', 'Murphy', 'Cook', 'Ramos', | |||
| 'Richardson', 'Cox', 'Howard', 'Bell', 'Ortiz', 'Gutierrez', 'Chavez', 'Wood', 'James', 'Bennett', | |||
| 'Gray', 'Mendoza', 'Ruiz', 'Hughes', 'Grant', 'Stone', 'Spencer', 'Warren', 'Porter', 'Bryant', | |||
| ]; | |||
| $departments = [ | |||
| 'Engineering', 'Finance', 'Operations', 'Sales', 'Marketing', 'People', 'Support', 'Legal', | |||
| ]; | |||
| $jobTitles = [ | |||
| 'Coordinator', 'Analyst', 'Manager', 'Specialist', 'Administrator', 'Engineer', 'Consultant', 'Lead', | |||
| ]; | |||
| $statement = $database->pdo()->prepare( | |||
| 'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date) | |||
| VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)' | |||
| ); | |||
| $database->pdo()->beginTransaction(); | |||
| try { | |||
| for ($i = $currentTotal + 1; $i <= $targetTotal; $i++) { | |||
| $firstName = $firstNames[$i % count($firstNames)]; | |||
| $lastName = $lastNames[$i % count($lastNames)]; | |||
| $department = $departments[$i % count($departments)]; | |||
| $jobTitle = $jobTitles[$i % count($jobTitles)]; | |||
| $email = sprintf( | |||
| '%s.%s.%04d@example.test', | |||
| strtolower($firstName), | |||
| strtolower($lastName), | |||
| $i | |||
| ); | |||
| $month = (($i - 1) % 12) + 1; | |||
| $day = (($i - 1) % 28) + 1; | |||
| $year = 2019 + (($i - 1) % 8); | |||
| $startDate = sprintf('%04d-%02d-%02d', $year, $month, $day); | |||
| $statement->execute([ | |||
| 'first_name' => $firstName, | |||
| 'last_name' => $lastName, | |||
| 'email' => $email, | |||
| 'department' => $department, | |||
| 'job_title' => $jobTitle, | |||
| 'start_date' => $startDate, | |||
| ]); | |||
| } | |||
| $database->pdo()->commit(); | |||
| } catch (Throwable $exception) { | |||
| $database->pdo()->rollBack(); | |||
| throw $exception; | |||
| } | |||
| $inserted = $targetTotal - $currentTotal; | |||
| echo "Inserted {$inserted} sample employees. Total is now {$targetTotal}." . PHP_EOL; | |||
| } | |||
| if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { | |||
| $targetTotal = isset($argv[1]) ? max(1, (int) $argv[1]) : 1000; | |||
| seed_employees($targetTotal); | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| services: | |||
| app: | |||
| build: . | |||
| ports: | |||
| - "8080:80" | |||
| volumes: | |||
| - .:/var/www/html | |||
| env_file: .env | |||
| @@ -0,0 +1,17 @@ | |||
| #!/bin/bash | |||
| set -e | |||
| mkdir -p database | |||
| chmod 777 database | |||
| composer install --no-interaction --quiet | |||
| # Runs as root — migrations may create database/app.sqlite owned by root. | |||
| php scripts/migrate.php up | |||
| # Fix ownership after migrations so www-data (Apache) can write. | |||
| # chmod works on Windows volume mounts even when chown is ignored by p9fs. | |||
| chmod 777 database | |||
| chmod 666 database/app.sqlite | |||
| exec apache2-foreground | |||
| @@ -0,0 +1,13 @@ | |||
| <VirtualHost *:80> | |||
| ServerName localhost | |||
| DocumentRoot /var/www/html/public | |||
| <Directory /var/www/html/public> | |||
| Options -Indexes +FollowSymLinks | |||
| AllowOverride All | |||
| Require all granted | |||
| </Directory> | |||
| ErrorLog ${APACHE_LOG_DIR}/error.log | |||
| CustomLog ${APACHE_LOG_DIR}/access.log combined | |||
| </VirtualHost> | |||
| @@ -0,0 +1,78 @@ | |||
| # MindVisionCode PHP | |||
| A small PHP MVC framework inspired by a Classic ASP MVC framework. | |||
| ## Run | |||
| ```bash | |||
| composer install | |||
| php scripts/migrate.php up | |||
| php -S localhost:8000 -t public | |||
| ``` | |||
| Open: | |||
| ```text | |||
| http://localhost:8000 | |||
| ``` | |||
| Try: | |||
| ```text | |||
| http://localhost:8000/users/123 | |||
| ``` | |||
| Employee form: | |||
| ```text | |||
| http://localhost:8000/employees | |||
| ``` | |||
| ## Request Flow | |||
| Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response | |||
| ## Main Folders | |||
| - `core/` framework classes | |||
| - `app/Controllers/` application controllers | |||
| - `app/ViewModels/` view model classes | |||
| - `app/Repositories/` data access classes | |||
| - `app/Views/` PHP templates | |||
| - `routes/web.php` route definitions | |||
| - `database/migrations/` migrations | |||
| - `scripts/` runnable PHP CLI scripts | |||
| ## SQLite | |||
| The default database is SQLite and points to: | |||
| ```text | |||
| database/app.sqlite | |||
| ``` | |||
| The database file is created automatically when the app first needs it. | |||
| Run migrations from the PHP CLI: | |||
| ```bash | |||
| php scripts/migrate.php up | |||
| php scripts/migrate.php down | |||
| php scripts/migrate.php status | |||
| php scripts/migrate.php make create_projects_table | |||
| php scripts/migrate.php fresh | |||
| php scripts/migrate.php fresh --seed | |||
| php scripts/seed_employees.php 1000 | |||
| ``` | |||
| ## Frontend Libraries | |||
| The employee directory page uses: | |||
| - `htmx` for fragment-based form and summary updates | |||
| - `Alpine.js` for lightweight page state | |||
| - `Tabulator` for the interactive employee table | |||
| ## Flow chart | |||
| See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework. | |||
| @@ -0,0 +1,80 @@ | |||
| # Request / Response Flow | |||
| This chart shows how a browser request moves through the MindVisionCode framework and how a response is built and returned. | |||
| ```text | |||
| Browser Request | |||
| | | |||
| v | |||
| public/index.php | |||
| |-- loads autoload.php / vendor autoload | |||
| |-- starts the session | |||
| |-- creates App + Router | |||
| |-- loads routes/web.php | |||
| | | |||
| v | |||
| Request::capture() | |||
| | | |||
| v | |||
| Dispatcher::dispatch() | |||
| | | |||
| +--> no route matched ----> Response::notFound() | |||
| | | |||
| +--> route matched -------> Route::dispatch() | |||
| | | |||
| v | |||
| App::call() | |||
| | | |||
| +--> controller method | |||
| | | | |||
| | v | |||
| | Controller action | |||
| | | | |||
| | +--> repository / service / view model | |||
| | +--> Database::query() / execute() | |||
| | +--> view() / json() / redirect() | |||
| | | |||
| +--> closure route | |||
| | | |||
| v | |||
| direct response data | |||
| Dispatcher::normalizeResponse() | |||
| | | |||
| +--> Response object --------> Response::send() | |||
| +--> array ------------------> Response::json() --> Response::send() | |||
| +--> string -----------------> Response::send() | |||
| Final result: | |||
| Browser receives HTML, JSON, or a redirect | |||
| ``` | |||
| ## Response building paths | |||
| ### View response | |||
| ```text | |||
| Controller -> view() -> View::render() -> template -> layout -> Response | |||
| ``` | |||
| ### JSON response | |||
| ```text | |||
| Controller -> json() -> Response::json() -> Response::send() | |||
| ``` | |||
| ### Redirect response | |||
| ```text | |||
| Controller -> redirect() -> Response::redirect() -> Response::send() | |||
| ``` | |||
| ## Key classes | |||
| - `public/index.php` bootstraps the app | |||
| - `Core\Dispatcher` matches routes and handles errors | |||
| - `Core\Route` extracts route parameters | |||
| - `Core\App` invokes controller methods or closures | |||
| - `Core\Controller` gives actions helper methods | |||
| - `Core\View` renders templates into a layout | |||
| - `Core\Response` sends the final output | |||
| @@ -0,0 +1,5 @@ | |||
| RewriteEngine On | |||
| RewriteCond %{REQUEST_FILENAME} !-f | |||
| RewriteCond %{REQUEST_FILENAME} !-d | |||
| RewriteRule ^ index.php [QSA,L] | |||
| @@ -0,0 +1,507 @@ | |||
| /* Kanban board theme aligned with public/css/site.css */ | |||
| html, | |||
| body.kanban-page { | |||
| height: 100%; | |||
| } | |||
| body.kanban-page { | |||
| margin: 0; | |||
| overflow: hidden; | |||
| background: | |||
| radial-gradient(75rem 35rem at -12% -18%, #dae8ff 0%, transparent 44%), | |||
| radial-gradient(68rem 32rem at 115% -16%, #d8f3ff 0%, transparent 40%), | |||
| linear-gradient(180deg, #eef4ff 0%, #f4f8ff 58%, #f3f7ff 100%); | |||
| } | |||
| /* Top bar */ | |||
| body.kanban-page .navbar { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 1000; | |||
| border-bottom: 1px solid rgba(255, 255, 255, 0.18); | |||
| backdrop-filter: blur(9px); | |||
| background: linear-gradient(120deg, #102241 0%, #173a72 56%, #1c4c90 100%); | |||
| box-shadow: 0 8px 24px rgba(8, 20, 48, 0.26); | |||
| } | |||
| body.kanban-page .navbar-brand { | |||
| color: #f4f8ff !important; | |||
| letter-spacing: -0.01em; | |||
| } | |||
| .board-header-main { | |||
| min-width: 0; | |||
| } | |||
| .board-header-actions { | |||
| flex-shrink: 0; | |||
| } | |||
| .board-header-search { | |||
| width: min(440px, 36vw); | |||
| margin: 0 0.75rem; | |||
| } | |||
| .board-header-search .input-group-text { | |||
| border-color: rgba(214, 229, 250, 0.7); | |||
| background: rgba(255, 255, 255, 0.15); | |||
| color: #eff6ff; | |||
| } | |||
| .board-header-search .form-control { | |||
| border-color: rgba(214, 229, 250, 0.7); | |||
| background: rgba(255, 255, 255, 0.17); | |||
| color: #f5f9ff; | |||
| } | |||
| .board-header-search .form-control::placeholder { | |||
| color: rgba(235, 243, 255, 0.72); | |||
| } | |||
| .board-header-search .form-control:focus { | |||
| border-color: rgba(235, 245, 255, 0.95); | |||
| box-shadow: 0 0 0 0.2rem rgba(157, 194, 245, 0.25); | |||
| background: rgba(255, 255, 255, 0.23); | |||
| } | |||
| .kanban-board-title { | |||
| display: block; | |||
| min-width: 0; | |||
| max-width: 100%; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| white-space: nowrap; | |||
| font-size: clamp(1rem, 0.6vw + 0.95rem, 1.28rem); | |||
| } | |||
| body.kanban-page .navbar .btn-outline-light, | |||
| body.kanban-page .navbar .btn-outline-secondary { | |||
| border-color: rgba(213, 230, 255, 0.55) !important; | |||
| color: #eef5ff !important; | |||
| background: rgba(255, 255, 255, 0.06); | |||
| } | |||
| body.kanban-page .navbar .btn-outline-light:hover, | |||
| body.kanban-page .navbar .btn-outline-secondary:hover { | |||
| background: rgba(255, 255, 255, 0.16); | |||
| border-color: rgba(234, 243, 255, 0.85) !important; | |||
| } | |||
| /* Board wrapper */ | |||
| .kanban-wrapper { | |||
| width: 100%; | |||
| margin: 0 auto; | |||
| height: calc(100vh - 65px); | |||
| overflow-x: auto; | |||
| overflow-y: auto; | |||
| padding: 0.9rem 1rem 1.1rem; | |||
| -webkit-overflow-scrolling: touch; | |||
| scroll-behavior: smooth; | |||
| touch-action: pan-x pan-y; | |||
| overscroll-behavior: contain; | |||
| scrollbar-width: thin; | |||
| scrollbar-color: #8fb0e0 #dce8f8; | |||
| scrollbar-gutter: stable; | |||
| } | |||
| .kanban-grid { | |||
| display: grid; | |||
| width: max-content; | |||
| min-width: 100%; | |||
| border: 1px solid var(--line, #d9e3f5); | |||
| border-radius: 14px; | |||
| background: rgba(255, 255, 255, 0.72); | |||
| box-shadow: 0 12px 34px rgba(22, 48, 92, 0.12); | |||
| overflow: clip; | |||
| } | |||
| /* Sticky corner and headers */ | |||
| .kanban-corner { | |||
| position: sticky; | |||
| top: 0; | |||
| left: 0; | |||
| z-index: 40; | |||
| min-width: 240px; | |||
| min-height: 56px; | |||
| background: linear-gradient(135deg, #173864 0%, #1e4d8f 100%); | |||
| border-right: 1px solid rgba(255, 255, 255, 0.15); | |||
| border-bottom: 1px solid rgba(255, 255, 255, 0.15); | |||
| } | |||
| .kanban-col-header { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 30; | |||
| min-width: 230px; | |||
| padding: 0.75rem 0.88rem; | |||
| color: #eff5ff; | |||
| font-size: clamp(0.68rem, 0.16vw + 0.65rem, 0.78rem); | |||
| font-weight: 700; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.06em; | |||
| white-space: normal; | |||
| line-height: 1.22; | |||
| word-break: break-word; | |||
| overflow-wrap: anywhere; | |||
| background: linear-gradient(120deg, #173864 0%, #1e4d8f 100%); | |||
| border-left: 1px solid rgba(255, 255, 255, 0.16); | |||
| } | |||
| .kanban-lane-header { | |||
| position: sticky; | |||
| left: 0; | |||
| z-index: 20; | |||
| min-width: 240px; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 0.42rem; | |||
| padding: 0.85rem 0.82rem; | |||
| font-size: clamp(0.7rem, 0.14vw + 0.66rem, 0.78rem); | |||
| font-weight: 700; | |||
| color: #2a3a58; | |||
| background: linear-gradient(180deg, #f6faff 0%, #edf3ff 100%); | |||
| border-top: 1px solid #d7e1f5; | |||
| border-right: 1px solid #d4deef; | |||
| } | |||
| .kanban-lane-header .lane-label { | |||
| display: inline-block; | |||
| max-width: 100%; | |||
| white-space: normal; | |||
| line-height: 1.2; | |||
| word-break: break-word; | |||
| overflow-wrap: anywhere; | |||
| } | |||
| .kanban-lane-header .lane-toggle { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 1.34rem; | |||
| height: 1.34rem; | |||
| padding: 0; | |||
| border: 0; | |||
| border-radius: 999px; | |||
| background: rgba(26, 74, 145, 0.12); | |||
| color: #1f4f96; | |||
| cursor: pointer; | |||
| flex: 0 0 auto; | |||
| } | |||
| .kanban-lane-header .lane-toggle:hover { | |||
| background: rgba(26, 74, 145, 0.22); | |||
| } | |||
| .kanban-lane-header .lane-toggle i { | |||
| transition: transform 140ms ease; | |||
| } | |||
| .kanban-lane-header.lane-collapsed .lane-toggle i { | |||
| transform: rotate(-90deg); | |||
| } | |||
| /* Cells */ | |||
| .kanban-cell { | |||
| min-width: 230px; | |||
| min-height: 132px; | |||
| padding: 0.56rem; | |||
| border-top: 1px solid #dce5f4; | |||
| border-left: 1px solid #dce5f4; | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 0.45rem; | |||
| vertical-align: top; | |||
| background: | |||
| linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(244, 248, 255, 0.93) 100%); | |||
| } | |||
| .kanban-cell.lane-collapsed { | |||
| min-height: 0; | |||
| max-height: 0; | |||
| padding-top: 0; | |||
| padding-bottom: 0; | |||
| border-top-color: transparent; | |||
| overflow: hidden; | |||
| pointer-events: none; | |||
| } | |||
| .kanban-cell.lane-collapsed .kanban-card { | |||
| display: none !important; | |||
| } | |||
| .kanban-cell.drag-over { | |||
| background: linear-gradient(180deg, #e9f2ff 0%, #deecff 100%); | |||
| box-shadow: inset 0 0 0 2px rgba(19, 99, 223, 0.24); | |||
| } | |||
| /* Cards */ | |||
| .kanban-card { | |||
| border: 1px solid #d5e1f6; | |||
| border-radius: 12px; | |||
| background: #ffffff; | |||
| padding: 0.56rem 0.62rem; | |||
| cursor: pointer; | |||
| user-select: none; | |||
| box-shadow: 0 4px 12px rgba(16, 44, 90, 0.08); | |||
| transition: transform 120ms ease, box-shadow 140ms ease, border-color 120ms ease; | |||
| touch-action: manipulation; | |||
| } | |||
| .kanban-card:hover { | |||
| transform: translateY(-1px); | |||
| border-color: #8eb0ea; | |||
| box-shadow: 0 10px 22px rgba(16, 44, 90, 0.14); | |||
| } | |||
| .kanban-card.sortable-ghost { | |||
| opacity: 0.38; | |||
| } | |||
| .kanban-card.sortable-chosen { | |||
| border-color: #4d87e2; | |||
| box-shadow: 0 14px 30px rgba(17, 46, 94, 0.22); | |||
| } | |||
| .kanban-card-hidden { | |||
| display: none !important; | |||
| } | |||
| .card-headline { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 0.38rem; | |||
| min-width: 0; | |||
| } | |||
| .card-job-number { | |||
| display: inline-block; | |||
| padding: 0.08rem 0.42rem; | |||
| border-radius: 999px; | |||
| font-size: 0.66rem; | |||
| font-weight: 800; | |||
| letter-spacing: 0.07em; | |||
| text-transform: uppercase; | |||
| color: #0e4fae; | |||
| background: #e7f0ff; | |||
| flex: 0 0 auto; | |||
| } | |||
| .card-customer { | |||
| font-size: 0.78rem; | |||
| font-weight: 600; | |||
| color: #2b4a80; | |||
| min-width: 0; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| } | |||
| .card-meta { | |||
| display: flex; | |||
| flex-wrap: wrap; | |||
| gap: 0.5rem; | |||
| margin-top: 0.25rem; | |||
| } | |||
| .card-meta-label { | |||
| font-size: 0.63rem; | |||
| font-weight: 700; | |||
| color: #7a90b2; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.05em; | |||
| } | |||
| .card-delivery, | |||
| .card-qty { | |||
| font-size: 0.73rem; | |||
| color: #3a5080; | |||
| } | |||
| .card-notes { | |||
| margin-top: 0.22rem; | |||
| font-size: 0.71rem; | |||
| color: #647899; | |||
| line-height: 1.3; | |||
| } | |||
| /* Settings panel */ | |||
| .kanban-settings-overlay { | |||
| position: fixed; | |||
| inset: 0; | |||
| z-index: 1040; | |||
| background: rgba(13, 25, 48, 0.42); | |||
| backdrop-filter: blur(2px); | |||
| } | |||
| .kanban-settings-panel { | |||
| position: fixed; | |||
| top: 0; | |||
| right: 0; | |||
| width: min(420px, 94vw); | |||
| max-width: 100vw; | |||
| height: 100%; | |||
| z-index: 1050; | |||
| background: linear-gradient(180deg, #f7fbff 0%, #f1f6ff 100%); | |||
| border-left: 1px solid #d8e2f2; | |||
| box-shadow: -8px 0 28px rgba(19, 40, 81, 0.2); | |||
| display: flex; | |||
| flex-direction: column; | |||
| overflow: hidden; | |||
| transform: translateX(104%); | |||
| transition: transform 180ms cubic-bezier(0.2, 0.7, 0.2, 1); | |||
| will-change: transform; | |||
| } | |||
| .kanban-settings-panel.open { | |||
| transform: translateX(0); | |||
| } | |||
| .settings-header { | |||
| background: rgba(255, 255, 255, 0.7); | |||
| border-bottom-color: #dce6f5 !important; | |||
| } | |||
| .settings-header h6 { | |||
| letter-spacing: -0.01em; | |||
| } | |||
| .settings-body { | |||
| flex: 1; | |||
| overflow-y: auto; | |||
| } | |||
| .settings-body .list-group-item { | |||
| border-color: #d7e2f4; | |||
| border-radius: 10px !important; | |||
| margin-bottom: 0.45rem; | |||
| background: #ffffff; | |||
| box-shadow: 0 3px 9px rgba(25, 45, 84, 0.05); | |||
| } | |||
| .settings-sortable .drag-handle { | |||
| color: #8fa1bf !important; | |||
| } | |||
| .settings-sortable .drag-handle:hover { | |||
| color: var(--brand, #1363df) !important; | |||
| } | |||
| .inline-rename { | |||
| height: auto; | |||
| font-size: 0.84rem; | |||
| padding: 0.23rem 0.45rem; | |||
| } | |||
| /* Modal polish */ | |||
| #cardModal .modal-content { | |||
| border: 1px solid #d7e2f5; | |||
| border-radius: 14px; | |||
| box-shadow: 0 22px 48px rgba(18, 43, 83, 0.22); | |||
| } | |||
| #cardModal .modal-header { | |||
| border-bottom-color: #dfe8f7; | |||
| background: #f7fbff; | |||
| } | |||
| #cardModal .modal-footer { | |||
| border-top-color: #dfe8f7; | |||
| background: #fbfdff; | |||
| } | |||
| /* Scrollbars */ | |||
| .kanban-wrapper::-webkit-scrollbar, | |||
| .settings-body::-webkit-scrollbar { | |||
| width: 12px; | |||
| height: 12px; | |||
| } | |||
| .kanban-wrapper::-webkit-scrollbar-track, | |||
| .settings-body::-webkit-scrollbar-track { | |||
| background: #dce8f8; | |||
| border-radius: 999px; | |||
| } | |||
| .kanban-wrapper::-webkit-scrollbar-thumb, | |||
| .settings-body::-webkit-scrollbar-thumb { | |||
| background: #8fb0e0; | |||
| border-radius: 999px; | |||
| border: 2px solid #dce8f8; | |||
| } | |||
| .kanban-wrapper::-webkit-scrollbar-thumb:hover, | |||
| .settings-body::-webkit-scrollbar-thumb:hover { | |||
| background: #5e8ecb; | |||
| } | |||
| .kanban-wrapper::-webkit-scrollbar-corner { | |||
| background: #dce8f8; | |||
| } | |||
| /* Small screens */ | |||
| @media (max-width: 900px) { | |||
| .kanban-wrapper { | |||
| padding: 0.7rem; | |||
| height: calc(100vh - 62px); | |||
| } | |||
| .kanban-corner, | |||
| .kanban-lane-header { | |||
| min-width: 190px; | |||
| } | |||
| .kanban-col-header, | |||
| .kanban-cell { | |||
| min-width: 206px; | |||
| } | |||
| .kanban-board-title { | |||
| font-size: clamp(0.92rem, 2.8vw, 1.1rem); | |||
| } | |||
| .board-header-actions { | |||
| gap: 0.35rem !important; | |||
| } | |||
| .board-header-search { | |||
| width: min(270px, 44vw); | |||
| margin: 0 0.35rem; | |||
| } | |||
| .board-header-actions .btn { | |||
| padding-left: 0.48rem; | |||
| padding-right: 0.48rem; | |||
| } | |||
| } | |||
| @media (max-width: 640px) { | |||
| .board-header-search { | |||
| width: 100%; | |||
| margin: 0.5rem 0 0; | |||
| order: 3; | |||
| } | |||
| body.kanban-page .navbar { | |||
| flex-wrap: wrap; | |||
| align-items: flex-start !important; | |||
| } | |||
| .board-header-main { | |||
| flex: 1 1 auto; | |||
| } | |||
| .board-header-actions { | |||
| flex: 0 0 auto; | |||
| } | |||
| .kanban-settings-panel { | |||
| width: 100vw; | |||
| border-left: 0; | |||
| box-shadow: none; | |||
| } | |||
| .settings-body { | |||
| padding-bottom: 1.25rem; | |||
| } | |||
| } | |||
| @@ -0,0 +1,791 @@ | |||
| :root { | |||
| --page-background: #f4efe7; | |||
| --surface: rgba(255, 252, 247, 0.88); | |||
| --surface-strong: #fffdf8; | |||
| --surface-border: rgba(26, 72, 64, 0.12); | |||
| --text-primary: #143631; | |||
| --text-secondary: #4f655f; | |||
| --accent: #1d7a6d; | |||
| --accent-strong: #135c52; | |||
| --accent-soft: #daf1ec; | |||
| --highlight: #ef7c4d; | |||
| --shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1); | |||
| --shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08); | |||
| } | |||
| * { | |||
| box-sizing: border-box; | |||
| } | |||
| html { | |||
| scroll-behavior: smooth; | |||
| } | |||
| body { | |||
| margin: 0; | |||
| min-height: 100vh; | |||
| font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif; | |||
| color: var(--text-primary); | |||
| background: | |||
| radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%), | |||
| radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%), | |||
| linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%); | |||
| } | |||
| a { | |||
| color: inherit; | |||
| } | |||
| code { | |||
| font-family: Consolas, "Courier New", monospace; | |||
| } | |||
| .page-shell { | |||
| min-height: 100vh; | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| .container { | |||
| width: min(1120px, calc(100% - 2rem)); | |||
| margin: 0 auto; | |||
| } | |||
| .site-header { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 20; | |||
| backdrop-filter: blur(14px); | |||
| background: rgba(248, 242, 232, 0.78); | |||
| border-bottom: 1px solid rgba(20, 54, 49, 0.08); | |||
| } | |||
| .header-inner { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| padding: 1rem 0; | |||
| } | |||
| .brand { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| gap: 0.85rem; | |||
| text-decoration: none; | |||
| } | |||
| .brand-mark { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 2.75rem; | |||
| height: 2.75rem; | |||
| border-radius: 0.95rem; | |||
| background: linear-gradient(135deg, var(--accent), var(--highlight)); | |||
| color: #fff; | |||
| font-weight: 700; | |||
| letter-spacing: 0.08em; | |||
| box-shadow: var(--shadow-soft); | |||
| } | |||
| .brand-copy { | |||
| display: flex; | |||
| flex-direction: column; | |||
| line-height: 1.1; | |||
| } | |||
| .brand-copy strong { | |||
| font-size: 1rem; | |||
| } | |||
| .brand-copy small { | |||
| color: var(--text-secondary); | |||
| font-size: 0.75rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.14em; | |||
| } | |||
| .site-nav { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 0.6rem; | |||
| flex-wrap: wrap; | |||
| } | |||
| .nav-link { | |||
| text-decoration: none; | |||
| color: var(--text-secondary); | |||
| font-weight: 600; | |||
| padding: 0.7rem 1rem; | |||
| border-radius: 999px; | |||
| transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; | |||
| } | |||
| .nav-link:hover, | |||
| .nav-link:focus-visible, | |||
| .nav-link.is-active { | |||
| color: var(--accent-strong); | |||
| background: rgba(29, 122, 109, 0.12); | |||
| transform: translateY(-1px); | |||
| } | |||
| .page-content { | |||
| flex: 1; | |||
| padding: 3.5rem 0 4rem; | |||
| } | |||
| .content-stack { | |||
| display: grid; | |||
| gap: 1.5rem; | |||
| } | |||
| .section-heading { | |||
| max-width: 46rem; | |||
| } | |||
| .section-heading h1 { | |||
| margin: 0.3rem 0 0.8rem; | |||
| font-size: clamp(2.4rem, 5vw, 4rem); | |||
| line-height: 1; | |||
| letter-spacing: -0.04em; | |||
| } | |||
| .section-heading p { | |||
| margin: 0; | |||
| color: var(--text-secondary); | |||
| line-height: 1.8; | |||
| font-size: 1.05rem; | |||
| } | |||
| .hero { | |||
| display: grid; | |||
| grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr); | |||
| gap: 1.5rem; | |||
| align-items: stretch; | |||
| } | |||
| .hero-copy, | |||
| .hero-panel, | |||
| .feature-card, | |||
| .section-panel, | |||
| .employee-card, | |||
| .alert, | |||
| .empty-state { | |||
| background: var(--surface); | |||
| border: 1px solid var(--surface-border); | |||
| box-shadow: var(--shadow-card); | |||
| } | |||
| .hero-copy { | |||
| padding: 3rem; | |||
| border-radius: 2rem; | |||
| } | |||
| .eyebrow { | |||
| display: inline-block; | |||
| margin-bottom: 1rem; | |||
| padding: 0.4rem 0.75rem; | |||
| border-radius: 999px; | |||
| background: var(--accent-soft); | |||
| color: var(--accent-strong); | |||
| font-size: 0.78rem; | |||
| font-weight: 700; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.14em; | |||
| } | |||
| .hero h1 { | |||
| margin: 0; | |||
| font-size: clamp(2.8rem, 6vw, 4.8rem); | |||
| line-height: 0.98; | |||
| letter-spacing: -0.04em; | |||
| } | |||
| .hero-text { | |||
| max-width: 44rem; | |||
| margin: 1.25rem 0 0; | |||
| font-size: 1.12rem; | |||
| line-height: 1.8; | |||
| color: var(--text-secondary); | |||
| } | |||
| .hero-actions { | |||
| display: flex; | |||
| flex-wrap: wrap; | |||
| gap: 0.85rem; | |||
| margin-top: 2rem; | |||
| } | |||
| .button { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| padding: 0.9rem 1.35rem; | |||
| border-radius: 999px; | |||
| text-decoration: none; | |||
| font-weight: 700; | |||
| } | |||
| .button-primary { | |||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||
| color: #fff; | |||
| box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25); | |||
| } | |||
| .button-secondary { | |||
| background: rgba(29, 122, 109, 0.08); | |||
| color: var(--accent-strong); | |||
| } | |||
| .hero-panel { | |||
| display: flex; | |||
| flex-direction: column; | |||
| justify-content: space-between; | |||
| padding: 2rem; | |||
| border-radius: 1.8rem; | |||
| } | |||
| .panel-label { | |||
| margin: 0 0 1rem; | |||
| font-size: 0.78rem; | |||
| font-weight: 700; | |||
| letter-spacing: 0.16em; | |||
| text-transform: uppercase; | |||
| color: var(--text-secondary); | |||
| } | |||
| .hero-panel code { | |||
| display: block; | |||
| padding: 1rem 1.1rem; | |||
| border-radius: 1.2rem; | |||
| background: #173d37; | |||
| color: #eefbf6; | |||
| line-height: 1.7; | |||
| white-space: normal; | |||
| } | |||
| .route-callout { | |||
| margin-top: 1.5rem; | |||
| padding: 1rem 1.1rem; | |||
| border-radius: 1.2rem; | |||
| background: var(--surface-strong); | |||
| } | |||
| .route-callout span { | |||
| display: block; | |||
| margin-bottom: 0.45rem; | |||
| color: var(--text-secondary); | |||
| font-size: 0.92rem; | |||
| } | |||
| .route-callout a { | |||
| color: var(--highlight); | |||
| font-weight: 700; | |||
| text-decoration: none; | |||
| } | |||
| .feature-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||
| gap: 1.25rem; | |||
| margin-top: 1.5rem; | |||
| } | |||
| .feature-card { | |||
| padding: 1.75rem; | |||
| border-radius: 1.6rem; | |||
| } | |||
| .feature-card h2 { | |||
| margin-top: 0; | |||
| margin-bottom: 0.8rem; | |||
| font-size: 1.25rem; | |||
| } | |||
| .feature-card p { | |||
| margin: 0; | |||
| color: var(--text-secondary); | |||
| line-height: 1.7; | |||
| } | |||
| .employee-layout { | |||
| display: grid; | |||
| grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.5fr); | |||
| gap: 1.5rem; | |||
| align-items: start; | |||
| } | |||
| .controls-panel, | |||
| .table-shell { | |||
| overflow: hidden; | |||
| background: | |||
| linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)), | |||
| var(--surface); | |||
| } | |||
| .controls-header { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| } | |||
| .search-row { | |||
| display: grid; | |||
| grid-template-columns: minmax(0, 1fr); | |||
| } | |||
| .field-full { | |||
| width: 100%; | |||
| } | |||
| .section-panel { | |||
| padding: 1.75rem; | |||
| border-radius: 1.8rem; | |||
| } | |||
| .panel-header { | |||
| margin-bottom: 1.5rem; | |||
| } | |||
| .panel-header h2 { | |||
| margin: 0 0 0.45rem; | |||
| font-size: 1.45rem; | |||
| } | |||
| .panel-header p { | |||
| margin: 0; | |||
| color: var(--text-secondary); | |||
| line-height: 1.7; | |||
| } | |||
| .employee-form { | |||
| display: grid; | |||
| gap: 1.25rem; | |||
| } | |||
| .form-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |||
| gap: 1rem; | |||
| } | |||
| .field { | |||
| display: grid; | |||
| gap: 0.45rem; | |||
| font-weight: 600; | |||
| } | |||
| .field span { | |||
| font-size: 0.96rem; | |||
| } | |||
| .input { | |||
| width: 100%; | |||
| padding: 0.95rem 1rem; | |||
| border: 1px solid rgba(20, 54, 49, 0.16); | |||
| border-radius: 1rem; | |||
| background: rgba(255, 255, 255, 0.92); | |||
| color: var(--text-primary); | |||
| font: inherit; | |||
| } | |||
| .input:focus { | |||
| outline: 2px solid rgba(29, 122, 109, 0.22); | |||
| border-color: rgba(29, 122, 109, 0.45); | |||
| } | |||
| .field-error { | |||
| color: #a43d1f; | |||
| font-size: 0.88rem; | |||
| font-weight: 600; | |||
| } | |||
| .form-actions { | |||
| display: flex; | |||
| justify-content: flex-start; | |||
| align-items: center; | |||
| gap: 0.85rem; | |||
| } | |||
| .button { | |||
| border: 0; | |||
| cursor: pointer; | |||
| } | |||
| .htmx-indicator { | |||
| display: none; | |||
| } | |||
| .htmx-request .htmx-indicator, | |||
| .htmx-request.htmx-indicator { | |||
| display: inline-flex; | |||
| } | |||
| .inline-indicator { | |||
| color: var(--text-secondary); | |||
| font-size: 0.9rem; | |||
| font-weight: 600; | |||
| } | |||
| .alert, | |||
| .empty-state { | |||
| padding: 1rem 1.15rem; | |||
| border-radius: 1.2rem; | |||
| } | |||
| .alert-success { | |||
| background: rgba(218, 241, 236, 0.92); | |||
| color: var(--accent-strong); | |||
| } | |||
| .alert-error { | |||
| background: rgba(239, 124, 77, 0.14); | |||
| color: #8f3518; | |||
| } | |||
| .empty-state p { | |||
| margin: 0; | |||
| color: var(--text-secondary); | |||
| line-height: 1.7; | |||
| } | |||
| .empty-state p + p { | |||
| margin-top: 0.45rem; | |||
| } | |||
| .employee-cards { | |||
| display: grid; | |||
| gap: 1rem; | |||
| } | |||
| .employee-card { | |||
| padding: 1.15rem; | |||
| border-radius: 1.3rem; | |||
| } | |||
| .employee-card-top { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| margin-bottom: 0.8rem; | |||
| } | |||
| .employee-card-top h3 { | |||
| margin: 0; | |||
| font-size: 1.05rem; | |||
| } | |||
| .employee-card-top span { | |||
| padding: 0.4rem 0.7rem; | |||
| border-radius: 999px; | |||
| background: rgba(29, 122, 109, 0.09); | |||
| color: var(--accent-strong); | |||
| font-size: 0.78rem; | |||
| font-weight: 700; | |||
| } | |||
| .employee-card p { | |||
| margin: 0 0 1rem; | |||
| color: var(--text-secondary); | |||
| } | |||
| .employee-meta { | |||
| display: grid; | |||
| gap: 0.75rem; | |||
| margin: 0; | |||
| } | |||
| .employee-meta div { | |||
| display: grid; | |||
| gap: 0.2rem; | |||
| } | |||
| .employee-meta dt { | |||
| color: var(--text-secondary); | |||
| font-size: 0.82rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| } | |||
| .employee-meta dd { | |||
| margin: 0; | |||
| font-weight: 600; | |||
| } | |||
| .stats-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||
| gap: 0.9rem; | |||
| } | |||
| .stat-card { | |||
| padding: 1rem; | |||
| border-radius: 1.3rem; | |||
| background: rgba(255, 255, 255, 0.72); | |||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||
| } | |||
| .stat-card span { | |||
| display: block; | |||
| color: var(--text-secondary); | |||
| font-size: 0.82rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| } | |||
| .stat-card strong { | |||
| display: block; | |||
| margin-top: 0.45rem; | |||
| font-size: 1.7rem; | |||
| line-height: 1; | |||
| } | |||
| .summary-feature { | |||
| margin-top: 1rem; | |||
| padding: 1.15rem; | |||
| border-radius: 1.3rem; | |||
| background: linear-gradient(135deg, rgba(29, 122, 109, 0.12), rgba(239, 124, 77, 0.12)); | |||
| } | |||
| .summary-label { | |||
| display: block; | |||
| color: var(--text-secondary); | |||
| font-size: 0.82rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| } | |||
| .summary-feature h3 { | |||
| margin: 0.55rem 0 0.3rem; | |||
| font-size: 1.35rem; | |||
| } | |||
| .summary-feature p { | |||
| margin: 0; | |||
| color: var(--text-secondary); | |||
| } | |||
| .table-toolbar { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| margin-bottom: 1rem; | |||
| flex-wrap: wrap; | |||
| padding: 0.9rem 1rem; | |||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||
| border-radius: 1rem; | |||
| background: rgba(255, 255, 255, 0.58); | |||
| } | |||
| .table-pill { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| padding: 0.5rem 0.8rem; | |||
| border-radius: 999px; | |||
| background: rgba(29, 122, 109, 0.12); | |||
| color: var(--accent-strong); | |||
| font-size: 0.82rem; | |||
| font-weight: 700; | |||
| letter-spacing: 0.04em; | |||
| } | |||
| .table-caption { | |||
| color: var(--text-secondary); | |||
| font-size: 0.92rem; | |||
| } | |||
| .directory-panel .tabulator-host { | |||
| min-height: 38rem; | |||
| } | |||
| .tabulator-host .tabulator { | |||
| border: 1px solid var(--surface-border); | |||
| border-radius: 1.35rem; | |||
| overflow: hidden; | |||
| background: rgba(255, 255, 255, 0.82); | |||
| box-shadow: | |||
| inset 0 1px 0 rgba(255, 255, 255, 0.5), | |||
| 0 18px 35px rgba(20, 54, 49, 0.08); | |||
| } | |||
| .tabulator-host .tabulator-header { | |||
| border-bottom: 1px solid rgba(20, 54, 49, 0.08); | |||
| background: linear-gradient(180deg, rgba(29, 122, 109, 0.14), rgba(29, 122, 109, 0.08)); | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col { | |||
| min-height: 3.25rem; | |||
| background: transparent; | |||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col:last-child { | |||
| border-right: 0; | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-content { | |||
| padding: 0.9rem 0.95rem 0.85rem; | |||
| } | |||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-title { | |||
| font-size: 0.78rem; | |||
| font-weight: 800; | |||
| letter-spacing: 0.08em; | |||
| text-transform: uppercase; | |||
| color: var(--accent-strong); | |||
| } | |||
| .tabulator-host .tabulator-col, | |||
| .tabulator-host .tabulator-cell { | |||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||
| } | |||
| .tabulator-host .tabulator-row .tabulator-cell:last-child { | |||
| border-right: 0; | |||
| } | |||
| .tabulator-host .tabulator-row { | |||
| background: rgba(255, 255, 255, 0.96); | |||
| border-bottom: 1px solid rgba(20, 54, 49, 0.06); | |||
| transition: background-color 160ms ease, transform 160ms ease; | |||
| } | |||
| .tabulator-host .tabulator-row:nth-child(even) { | |||
| background: rgba(248, 242, 232, 0.82); | |||
| } | |||
| .tabulator-host .tabulator-row:hover { | |||
| background: rgba(218, 241, 236, 0.72); | |||
| } | |||
| .tabulator-host .tabulator-row.tabulator-selected { | |||
| background: rgba(29, 122, 109, 0.18); | |||
| } | |||
| .tabulator-host .tabulator-cell { | |||
| padding: 0.95rem 0.95rem; | |||
| font-size: 0.96rem; | |||
| line-height: 1.4; | |||
| } | |||
| .tabulator-host .tabulator-row .tabulator-cell:first-child { | |||
| font-weight: 700; | |||
| color: var(--text-primary); | |||
| } | |||
| .tabulator-host .tabulator-footer { | |||
| padding: 0.55rem 0.7rem; | |||
| background: rgba(255, 255, 255, 0.88); | |||
| border-top: 1px solid rgba(20, 54, 49, 0.08); | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-paginator { | |||
| font-family: inherit; | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-page { | |||
| margin: 0 0.2rem; | |||
| padding: 0.45rem 0.7rem; | |||
| border: 1px solid rgba(20, 54, 49, 0.1); | |||
| border-radius: 0.8rem; | |||
| background: rgba(255, 255, 255, 0.9); | |||
| color: var(--text-secondary); | |||
| font-weight: 700; | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-page.active, | |||
| .tabulator-host .tabulator-footer .tabulator-page:hover { | |||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||
| border-color: transparent; | |||
| color: #fff; | |||
| } | |||
| .tabulator-host .tabulator-footer .tabulator-page:disabled { | |||
| opacity: 0.45; | |||
| } | |||
| .tabulator-host .tabulator-placeholder { | |||
| padding: 2.5rem 1rem; | |||
| color: var(--text-secondary); | |||
| font-size: 1rem; | |||
| font-weight: 600; | |||
| } | |||
| .site-footer { | |||
| margin-top: auto; | |||
| border-top: 1px solid rgba(20, 54, 49, 0.08); | |||
| background: rgba(255, 252, 247, 0.72); | |||
| } | |||
| .footer-inner { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| padding: 1.25rem 0 2rem; | |||
| color: var(--text-secondary); | |||
| font-size: 0.95rem; | |||
| } | |||
| .footer-inner p { | |||
| margin: 0; | |||
| } | |||
| @media (max-width: 860px) { | |||
| .header-inner, | |||
| .footer-inner { | |||
| flex-direction: column; | |||
| align-items: flex-start; | |||
| } | |||
| .hero, | |||
| .feature-grid, | |||
| .employee-layout { | |||
| grid-template-columns: 1fr; | |||
| } | |||
| .controls-header, | |||
| .table-toolbar { | |||
| flex-direction: column; | |||
| align-items: flex-start; | |||
| } | |||
| .hero-copy, | |||
| .hero-panel { | |||
| padding: 2rem; | |||
| } | |||
| .form-grid { | |||
| grid-template-columns: 1fr; | |||
| } | |||
| .stats-grid { | |||
| grid-template-columns: 1fr; | |||
| } | |||
| .page-content { | |||
| padding-top: 2rem; | |||
| } | |||
| } | |||
| @media (max-width: 560px) { | |||
| .container { | |||
| width: min(100% - 1.25rem, 1120px); | |||
| } | |||
| .site-nav { | |||
| width: 100%; | |||
| } | |||
| .nav-link { | |||
| width: 100%; | |||
| text-align: center; | |||
| } | |||
| .hero h1 { | |||
| font-size: 2.5rem; | |||
| } | |||
| } | |||
| @@ -0,0 +1,45 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||
| // Load .env file if present — sets vars via putenv() so getenv() picks them up | |||
| (static function (): void { | |||
| $envFile = __DIR__ . '/../.env'; | |||
| if (!file_exists($envFile)) { | |||
| return; | |||
| } | |||
| foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { | |||
| $line = trim($line); | |||
| if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) { | |||
| continue; | |||
| } | |||
| [$name, $value] = explode('=', $line, 2); | |||
| $name = trim($name); | |||
| $value = trim($value); | |||
| if ($name !== '' && getenv($name) === false) { | |||
| putenv("{$name}={$value}"); | |||
| $_ENV[$name] = $value; | |||
| } | |||
| } | |||
| })(); | |||
| use Core\Dispatcher; | |||
| use Core\Request; | |||
| use Core\Router; | |||
| ensureSessionStarted(); | |||
| $app = app(); | |||
| $router = new Router(); | |||
| require_once __DIR__ . '/../routes/web.php'; | |||
| $debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); | |||
| $dispatcher = new Dispatcher($router, $app, $debug); | |||
| $request = Request::capture(); | |||
| $app->bind(Request::class, $request); | |||
| $response = $dispatcher->dispatch($request); | |||
| $response->send(); | |||
| @@ -0,0 +1,65 @@ | |||
| window.employeeDirectory = function () { | |||
| return { | |||
| search: '', | |||
| table: null, | |||
| init() { | |||
| this.search = this.$root.querySelector('#employee-search')?.value ?? ''; | |||
| this.initTable(); | |||
| document.body.addEventListener('employees-changed', () => { | |||
| this.reloadTable(); | |||
| }); | |||
| }, | |||
| initTable() { | |||
| const tableElement = document.getElementById('employee-table'); | |||
| if (!tableElement || typeof Tabulator === 'undefined') { | |||
| return; | |||
| } | |||
| this.table = new Tabulator(tableElement, { | |||
| ajaxURL: '/employees/data', | |||
| ajaxParams: { | |||
| search: this.search, | |||
| }, | |||
| layout: 'fitColumns', | |||
| responsiveLayout: 'collapse', | |||
| pagination: true, | |||
| paginationMode: 'local', | |||
| paginationSize: 8, | |||
| movableColumns: true, | |||
| placeholder: 'No employees found.', | |||
| columns: [ | |||
| { title: 'Name', field: 'full_name', minWidth: 180 }, | |||
| { title: 'Email', field: 'email', minWidth: 220 }, | |||
| { title: 'Department', field: 'department', minWidth: 140 }, | |||
| { title: 'Job Title', field: 'job_title', minWidth: 180 }, | |||
| { title: 'Start Date', field: 'start_date', hozAlign: 'left', minWidth: 130 }, | |||
| ], | |||
| }); | |||
| }, | |||
| applySearch() { | |||
| if (!this.table) { | |||
| return; | |||
| } | |||
| this.table.setData('/employees/data', { | |||
| search: this.search, | |||
| }); | |||
| }, | |||
| reloadTable() { | |||
| if (!this.table) { | |||
| this.initTable(); | |||
| return; | |||
| } | |||
| this.table.setData('/employees/data', { | |||
| search: this.search, | |||
| }); | |||
| }, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,442 @@ | |||
| /* kanban-board.js - grid rendering and drag-drop between cells */ | |||
| (function () { | |||
| 'use strict'; | |||
| var boardId = KANBAN.boardId; | |||
| var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | |||
| var collapsedLaneIds = loadCollapsedLaneIds(); | |||
| var searchState = { | |||
| query: '' | |||
| }; | |||
| var dragState = { | |||
| active: false, | |||
| x: 0, | |||
| y: 0, | |||
| rafId: 0 | |||
| }; | |||
| function post(url, data, cb) { | |||
| var params = new URLSearchParams(); | |||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||
| fetch(url, { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |||
| body: params.toString() | |||
| }) | |||
| .then(function (r) { return r.json(); }) | |||
| .then(cb) | |||
| .catch(function (e) { console.error(url, e); }); | |||
| } | |||
| function esc(s) { | |||
| return String(s) | |||
| .replace(/&/g, '&') | |||
| .replace(/</g, '<') | |||
| .replace(/>/g, '>') | |||
| .replace(/"/g, '"'); | |||
| } | |||
| function applyGridTemplate() { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| var colHs = grid.querySelectorAll('.kanban-col-header'); | |||
| var cols = '240px'; | |||
| colHs.forEach(function () { cols += ' 230px'; }); | |||
| grid.style.gridTemplateColumns = cols; | |||
| } | |||
| function loadCollapsedLaneIds() { | |||
| var laneMap = {}; | |||
| try { | |||
| var raw = window.localStorage.getItem(laneCollapseStorageKey); | |||
| if (!raw) return laneMap; | |||
| var arr = JSON.parse(raw); | |||
| if (!Array.isArray(arr)) return laneMap; | |||
| arr.forEach(function (laneId) { | |||
| laneMap[String(laneId)] = true; | |||
| }); | |||
| } catch (e) { | |||
| console.warn('Failed to load lane collapse state', e); | |||
| } | |||
| return laneMap; | |||
| } | |||
| function saveCollapsedLaneIds() { | |||
| try { | |||
| window.localStorage.setItem(laneCollapseStorageKey, JSON.stringify(Object.keys(collapsedLaneIds))); | |||
| } catch (e) { | |||
| console.warn('Failed to save lane collapse state', e); | |||
| } | |||
| } | |||
| function setLaneCollapsed(laneId, isCollapsed) { | |||
| var laneKey = String(laneId); | |||
| var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneKey + '"]'); | |||
| if (!header) return; | |||
| var laneCells = document.querySelectorAll('.kanban-cell[data-lane-id="' + laneKey + '"]'); | |||
| header.classList.toggle('lane-collapsed', isCollapsed); | |||
| laneCells.forEach(function (cell) { | |||
| cell.classList.toggle('lane-collapsed', isCollapsed); | |||
| }); | |||
| var toggleBtn = header.querySelector('.lane-toggle'); | |||
| if (toggleBtn) { | |||
| toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); | |||
| toggleBtn.title = isCollapsed ? 'Expand swim lane' : 'Collapse swim lane'; | |||
| toggleBtn.setAttribute('aria-label', toggleBtn.title); | |||
| } | |||
| if (isCollapsed) { | |||
| collapsedLaneIds[laneKey] = true; | |||
| } else { | |||
| delete collapsedLaneIds[laneKey]; | |||
| } | |||
| saveCollapsedLaneIds(); | |||
| } | |||
| function toggleLaneCollapsed(laneId) { | |||
| var laneKey = String(laneId); | |||
| setLaneCollapsed(laneKey, !collapsedLaneIds[laneKey]); | |||
| } | |||
| function bindLaneHeaderToggle(headerEl) { | |||
| if (!headerEl) return; | |||
| var toggleBtn = headerEl.querySelector('.lane-toggle'); | |||
| if (!toggleBtn) return; | |||
| if (!toggleBtn.dataset.boundToggle) { | |||
| toggleBtn.addEventListener('click', function (evt) { | |||
| evt.preventDefault(); | |||
| evt.stopPropagation(); | |||
| toggleLaneCollapsed(headerEl.dataset.laneId); | |||
| }); | |||
| toggleBtn.dataset.boundToggle = '1'; | |||
| } | |||
| } | |||
| function initLaneHeaderToggles() { | |||
| document.querySelectorAll('.kanban-lane-header').forEach(function (headerEl) { | |||
| bindLaneHeaderToggle(headerEl); | |||
| if (collapsedLaneIds[String(headerEl.dataset.laneId)]) { | |||
| setLaneCollapsed(headerEl.dataset.laneId, true); | |||
| } | |||
| }); | |||
| } | |||
| function cardBodyHtml(card) { | |||
| var html = '<div class="card-headline">' + | |||
| '<span class="card-job-number">' + esc(card.job_number || '') + '</span>'; | |||
| if (card.customer_name) { | |||
| html += '<span class="card-customer">' + esc(card.customer_name) + '</span>'; | |||
| } | |||
| html += '</div>'; | |||
| return html; | |||
| } | |||
| function buildCardSearchText(card) { | |||
| return [ | |||
| card.job_number || '', | |||
| card.job_name || '', | |||
| card.customer_name || '', | |||
| card.notes || '' | |||
| ].join(' ').toLowerCase(); | |||
| } | |||
| function buildCardEl(card) { | |||
| var div = document.createElement('div'); | |||
| div.className = 'kanban-card'; | |||
| div.dataset.id = card.id; | |||
| div.dataset.columnId = card.column_id; | |||
| div.dataset.laneId = card.swim_lane_id; | |||
| div.dataset.searchText = buildCardSearchText(card); | |||
| div.innerHTML = cardBodyHtml(card); | |||
| div.addEventListener('click', function () { | |||
| var c = KANBAN.cards.find(function (x) { return String(x.id) === String(div.dataset.id); }); | |||
| if (!c) return; | |||
| window.KanbanModal.openEdit(c.id, c.column_id, c.swim_lane_id, c.job_number, c.job_name, c.customer_name, c.delivery_date, c.quantity, c.notes, c.full_note); | |||
| }); | |||
| return div; | |||
| } | |||
| function renderCards() { | |||
| KANBAN.cards.forEach(function (card) { | |||
| var cell = document.querySelector( | |||
| '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | |||
| ); | |||
| if (cell) { | |||
| cell.appendChild(buildCardEl(card)); | |||
| } | |||
| }); | |||
| applyCardFilter(); | |||
| } | |||
| function applyCardFilter() { | |||
| var activeQuery = searchState.query; | |||
| document.querySelectorAll('.kanban-card').forEach(function (el) { | |||
| var searchableText = (el.dataset.searchText || '').toLowerCase(); | |||
| var isMatch = activeQuery === '' || searchableText.indexOf(activeQuery) > -1; | |||
| el.classList.toggle('kanban-card-hidden', !isMatch); | |||
| }); | |||
| } | |||
| function initJobSearch() { | |||
| var searchInput = document.getElementById('job-search-input'); | |||
| if (!searchInput) return; | |||
| searchInput.addEventListener('input', function () { | |||
| searchState.query = String(searchInput.value || '').toLowerCase().trim(); | |||
| applyCardFilter(); | |||
| }); | |||
| } | |||
| function handleDragEnd(evt) { | |||
| var cardId = evt.item.dataset.id; | |||
| var newColId = evt.to.dataset.colId; | |||
| var newLaneId = evt.to.dataset.laneId; | |||
| var newPos = evt.newIndex; | |||
| var siblings = []; | |||
| evt.to.querySelectorAll('.kanban-card').forEach(function (el) { | |||
| siblings.push(el.dataset.id); | |||
| }); | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); | |||
| if (card) { | |||
| card.column_id = parseInt(newColId, 10); | |||
| card.swim_lane_id = parseInt(newLaneId, 10); | |||
| card.position = newPos; | |||
| } | |||
| evt.item.dataset.columnId = newColId; | |||
| evt.item.dataset.laneId = newLaneId; | |||
| post('/cards/' + cardId + '/move', { | |||
| column_id: newColId, | |||
| swim_lane_id: newLaneId, | |||
| position: newPos, | |||
| sibling_ids: siblings.join(',') | |||
| }, function (res) { | |||
| if (!res.ok) console.error('Move failed', res); | |||
| }); | |||
| } | |||
| function createCellSortable(cell) { | |||
| Sortable.create(cell, { | |||
| group: 'cards', | |||
| animation: 150, | |||
| ghostClass: 'sortable-ghost', | |||
| chosenClass: 'sortable-chosen', | |||
| handle: '.kanban-card', | |||
| delayOnTouchOnly: true, | |||
| delay: 120, | |||
| touchStartThreshold: 3, | |||
| fallbackTolerance: 4, | |||
| scroll: true, | |||
| bubbleScroll: true, | |||
| scrollSensitivity: 140, | |||
| scrollSpeed: 32, | |||
| onStart: function () { startEdgeAutoScroll(); }, | |||
| onEnd: function (evt) { | |||
| stopEdgeAutoScroll(); | |||
| handleDragEnd(evt); | |||
| } | |||
| }); | |||
| } | |||
| function updatePointerFromEvent(evt) { | |||
| if (!evt) return; | |||
| if (evt.touches && evt.touches.length > 0) { | |||
| dragState.x = evt.touches[0].clientX; | |||
| dragState.y = evt.touches[0].clientY; | |||
| return; | |||
| } | |||
| if (evt.clientX !== undefined && evt.clientY !== undefined) { | |||
| dragState.x = evt.clientX; | |||
| dragState.y = evt.clientY; | |||
| } | |||
| } | |||
| function edgeScrollStep() { | |||
| if (!dragState.active) return; | |||
| var wrapper = document.querySelector('.kanban-wrapper'); | |||
| if (wrapper) { | |||
| var rect = wrapper.getBoundingClientRect(); | |||
| var edge = 110; | |||
| var maxStep = 40; | |||
| var dx = 0; | |||
| var dy = 0; | |||
| if (dragState.x > 0 && dragState.x < rect.left + edge) { | |||
| dx = -Math.min(maxStep, Math.ceil((rect.left + edge - dragState.x) / 3)); | |||
| } else if (dragState.x > rect.right - edge && dragState.x < rect.right + edge) { | |||
| dx = Math.min(maxStep, Math.ceil((dragState.x - (rect.right - edge)) / 3)); | |||
| } | |||
| if (dragState.y > 0 && dragState.y < rect.top + edge) { | |||
| dy = -Math.min(maxStep, Math.ceil((rect.top + edge - dragState.y) / 3)); | |||
| } else if (dragState.y > rect.bottom - edge && dragState.y < rect.bottom + edge) { | |||
| dy = Math.min(maxStep, Math.ceil((dragState.y - (rect.bottom - edge)) / 3)); | |||
| } | |||
| if (dx !== 0) wrapper.scrollLeft += dx; | |||
| if (dy !== 0) wrapper.scrollTop += dy; | |||
| } | |||
| dragState.rafId = window.requestAnimationFrame(edgeScrollStep); | |||
| } | |||
| function startEdgeAutoScroll() { | |||
| if (dragState.active) return; | |||
| dragState.active = true; | |||
| document.addEventListener('pointermove', updatePointerFromEvent, { passive: true }); | |||
| document.addEventListener('touchmove', updatePointerFromEvent, { passive: true }); | |||
| document.addEventListener('dragover', updatePointerFromEvent, { passive: true }); | |||
| dragState.rafId = window.requestAnimationFrame(edgeScrollStep); | |||
| } | |||
| function stopEdgeAutoScroll() { | |||
| if (!dragState.active) return; | |||
| dragState.active = false; | |||
| if (dragState.rafId) { | |||
| window.cancelAnimationFrame(dragState.rafId); | |||
| dragState.rafId = 0; | |||
| } | |||
| document.removeEventListener('pointermove', updatePointerFromEvent); | |||
| document.removeEventListener('touchmove', updatePointerFromEvent); | |||
| document.removeEventListener('dragover', updatePointerFromEvent); | |||
| } | |||
| function initSortables() { | |||
| document.querySelectorAll('.kanban-cell').forEach(createCellSortable); | |||
| } | |||
| document.getElementById('btn-add-card').addEventListener('click', function () { | |||
| window.KanbanModal.openCreate(boardId, null, null); | |||
| }); | |||
| window.KanbanBoard = { | |||
| onCardCreated: function (card) { | |||
| KANBAN.cards.push(card); | |||
| var cell = document.querySelector( | |||
| '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | |||
| ); | |||
| if (cell) { | |||
| cell.appendChild(buildCardEl(card)); | |||
| } | |||
| applyCardFilter(); | |||
| }, | |||
| onCardUpdated: function (id, data) { | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | |||
| if (card) { | |||
| card.job_number = data.job_number || ''; | |||
| card.job_name = data.job_name || ''; | |||
| card.customer_name = data.customer_name || ''; | |||
| card.delivery_date = data.delivery_date || null; | |||
| card.quantity = data.quantity || ''; | |||
| card.notes = data.notes || ''; | |||
| card.full_note = data.full_note !== undefined ? data.full_note : (card.full_note || ''); | |||
| } | |||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | |||
| if (el && card) { | |||
| el.innerHTML = cardBodyHtml(card); | |||
| el.dataset.searchText = buildCardSearchText(card); | |||
| } | |||
| applyCardFilter(); | |||
| }, | |||
| onCardDeleted: function (id) { | |||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); | |||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | |||
| if (el) el.remove(); | |||
| applyCardFilter(); | |||
| }, | |||
| addColumn: function (col) { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| var headers = grid.querySelectorAll('.kanban-col-header'); | |||
| var refNode = headers.length ? headers[headers.length - 1].nextSibling : null; | |||
| var hdr = document.createElement('div'); | |||
| hdr.className = 'kanban-col-header'; | |||
| hdr.dataset.colId = col.id; | |||
| hdr.innerHTML = '<span class="col-label">' + esc(col.name) + '</span>'; | |||
| grid.insertBefore(hdr, refNode); | |||
| var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); | |||
| laneHeaders.forEach(function (lh) { | |||
| var laneId = lh.dataset.laneId; | |||
| var cell = document.createElement('div'); | |||
| cell.className = 'kanban-cell'; | |||
| cell.dataset.colId = col.id; | |||
| cell.dataset.laneId = laneId; | |||
| var row = lh.parentNode; | |||
| row.appendChild(cell); | |||
| createCellSortable(cell); | |||
| }); | |||
| applyGridTemplate(); | |||
| }, | |||
| removeColumn: function (colId) { | |||
| document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); | |||
| document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); | |||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); | |||
| applyGridTemplate(); | |||
| }, | |||
| addLane: function (lane) { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| var colHeaders = grid.querySelectorAll('.kanban-col-header'); | |||
| var lh = document.createElement('div'); | |||
| lh.className = 'kanban-lane-header'; | |||
| lh.dataset.laneId = lane.id; | |||
| lh.innerHTML = | |||
| '<button type="button" class="lane-toggle" title="Collapse swim lane" aria-label="Collapse swim lane" aria-expanded="true">' + | |||
| '<i class="bi bi-chevron-down" aria-hidden="true"></i>' + | |||
| '</button>' + | |||
| '<span class="lane-label">' + esc(lane.name) + '</span>'; | |||
| grid.appendChild(lh); | |||
| bindLaneHeaderToggle(lh); | |||
| colHeaders.forEach(function (ch) { | |||
| var cell = document.createElement('div'); | |||
| cell.className = 'kanban-cell'; | |||
| cell.dataset.colId = ch.dataset.colId; | |||
| cell.dataset.laneId = lane.id; | |||
| grid.appendChild(cell); | |||
| createCellSortable(cell); | |||
| }); | |||
| if (collapsedLaneIds[String(lane.id)]) { | |||
| setLaneCollapsed(lane.id, true); | |||
| } | |||
| applyGridTemplate(); | |||
| }, | |||
| removeLane: function (laneId) { | |||
| document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | |||
| document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | |||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | |||
| if (collapsedLaneIds[String(laneId)]) { | |||
| delete collapsedLaneIds[String(laneId)]; | |||
| saveCollapsedLaneIds(); | |||
| } | |||
| }, | |||
| renameColumn: function (colId, name) { | |||
| var hdr = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"] .col-label'); | |||
| if (hdr) hdr.textContent = name; | |||
| }, | |||
| renameLane: function (laneId, name) { | |||
| var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label'); | |||
| if (hdr) hdr.textContent = name; | |||
| } | |||
| }; | |||
| applyGridTemplate(); | |||
| renderCards(); | |||
| initSortables(); | |||
| initJobSearch(); | |||
| initLaneHeaderToggles(); | |||
| })(); | |||
| @@ -0,0 +1,200 @@ | |||
| /* kanban-modal.js — card create/edit modal */ | |||
| (function () { | |||
| 'use strict'; | |||
| var modal = document.getElementById('cardModal'); | |||
| var bsModal = new bootstrap.Modal(modal); | |||
| var titleEl = document.getElementById('cardModalLabel'); | |||
| var cardIdEl = document.getElementById('card-id'); | |||
| var colIdEl = document.getElementById('card-column-id'); | |||
| var laneIdEl = document.getElementById('card-lane-id'); | |||
| var jobNumEl = document.getElementById('card-job-number'); | |||
| var jobNameEl = document.getElementById('card-job-name'); | |||
| var errEl = document.getElementById('card-modal-error'); | |||
| var btnSave = document.getElementById('btn-save-card'); | |||
| var btnDelete = document.getElementById('btn-delete-card'); | |||
| var custNameEl = document.getElementById('card-customer-name'); | |||
| var delivDateEl = document.getElementById('card-delivery-date'); | |||
| var qtyEl = document.getElementById('card-quantity'); | |||
| var notesEl = document.getElementById('card-notes'); | |||
| var fullNoteEl = document.getElementById('card-full-note'); | |||
| var boardId = KANBAN.boardId; | |||
| /* ── Helpers ─────────────────────────────────────────────── */ | |||
| function post(url, data, cb) { | |||
| var params = new URLSearchParams(); | |||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||
| fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) | |||
| .then(function (r) { return r.json(); }) | |||
| .then(cb) | |||
| .catch(function (e) { showError('Network error: ' + e); }); | |||
| } | |||
| function showError(msg) { | |||
| errEl.textContent = msg; | |||
| errEl.classList.remove('d-none'); | |||
| } | |||
| function clearError() { | |||
| errEl.textContent = ''; | |||
| errEl.classList.add('d-none'); | |||
| } | |||
| /* ── Open for create ──────────────────────────────────────── */ | |||
| function openCreate(bId, colId, laneId) { | |||
| titleEl.textContent = 'Add Card'; | |||
| cardIdEl.value = ''; | |||
| colIdEl.value = colId || ''; | |||
| laneIdEl.value = laneId || ''; | |||
| jobNumEl.value = ''; | |||
| jobNameEl.value = ''; | |||
| custNameEl.value = ''; | |||
| delivDateEl.value = ''; | |||
| qtyEl.value = ''; | |||
| notesEl.value = ''; | |||
| fullNoteEl.value = ''; | |||
| btnDelete.classList.add('d-none'); | |||
| clearError(); | |||
| bsModal.show(); | |||
| jobNumEl.focus(); | |||
| } | |||
| /* ── Open for edit ───────────────────────────────────────── */ | |||
| function openEdit(id, colId, laneId, jobNum, jobName, custName, delivDate, qty, notes, fullNote) { | |||
| titleEl.textContent = 'Edit Card'; | |||
| cardIdEl.value = id; | |||
| colIdEl.value = colId; | |||
| laneIdEl.value = laneId; | |||
| jobNumEl.value = jobNum || ''; | |||
| jobNameEl.value = jobName || ''; | |||
| custNameEl.value = custName || ''; | |||
| delivDateEl.value = delivDate || ''; | |||
| qtyEl.value = qty || ''; | |||
| notesEl.value = notes || ''; | |||
| fullNoteEl.value = fullNote || ''; | |||
| btnDelete.classList.remove('d-none'); | |||
| clearError(); | |||
| bsModal.show(); | |||
| jobNumEl.focus(); | |||
| } | |||
| /* ── Save ─────────────────────────────────────────────────── */ | |||
| btnSave.addEventListener('click', function () { | |||
| clearError(); | |||
| var id = cardIdEl.value; | |||
| var colId = colIdEl.value; | |||
| var laneId = laneIdEl.value; | |||
| var jNum = jobNumEl.value.trim(); | |||
| var jName = jobNameEl.value.trim(); | |||
| var cust = custNameEl.value.trim(); | |||
| var dDate = delivDateEl.value; | |||
| var qty = qtyEl.value.trim(); | |||
| var notes = notesEl.value.trim(); | |||
| var fullNote = fullNoteEl.value; | |||
| if (!jNum && !jName) { | |||
| showError('Enter at least a job number or job name.'); | |||
| return; | |||
| } | |||
| if (id) { | |||
| // Update existing | |||
| post('/cards/' + id, { job_number: jNum, job_name: jName, customer_name: cust, delivery_date: dDate, quantity: qty, notes: notes, full_note: fullNote }, function (res) { | |||
| if (res.ok) { | |||
| bsModal.hide(); | |||
| window.KanbanBoard.onCardUpdated(id, res); | |||
| } else { | |||
| showError(res.error || 'Save failed.'); | |||
| } | |||
| }); | |||
| } else { | |||
| // Create new — if no col/lane selected show column/lane picker | |||
| if (!colId || !laneId) { | |||
| showError('Please choose a column and swim lane first.'); | |||
| return; | |||
| } | |||
| post('/cards', { | |||
| board_id: boardId, | |||
| column_id: colId, | |||
| swim_lane_id: laneId, | |||
| job_number: jNum, | |||
| job_name: jName, | |||
| customer_name: cust, | |||
| delivery_date: dDate, | |||
| quantity: qty, | |||
| notes: notes, | |||
| full_note: fullNote | |||
| }, function (res) { | |||
| if (res.ok) { | |||
| bsModal.hide(); | |||
| window.KanbanBoard.onCardCreated(res); | |||
| } else { | |||
| showError(res.error || 'Save failed.'); | |||
| } | |||
| }); | |||
| } | |||
| }); | |||
| /* ── Delete ──────────────────────────────────────────────── */ | |||
| btnDelete.addEventListener('click', function () { | |||
| if (!confirm('Delete this card?')) return; | |||
| var id = cardIdEl.value; | |||
| post('/cards/' + id + '/delete', {}, function (res) { | |||
| if (res.ok) { | |||
| bsModal.hide(); | |||
| window.KanbanBoard.onCardDeleted(id); | |||
| } else { | |||
| showError(res.error || 'Delete failed.'); | |||
| } | |||
| }); | |||
| }); | |||
| /* ── Column/Lane picker when Add Card clicked with no cell ── */ | |||
| // Populated lazily from board data | |||
| modal.addEventListener('shown.bs.modal', function () { | |||
| if (!cardIdEl.value && (!colIdEl.value || !laneIdEl.value)) { | |||
| injectPicker(); | |||
| } | |||
| }); | |||
| function injectPicker() { | |||
| if (document.getElementById('card-picker')) return; | |||
| var picker = document.createElement('div'); | |||
| picker.id = 'card-picker'; | |||
| picker.className = 'row g-2 mb-3'; | |||
| var colSel = '<select class="form-select form-select-sm" id="pick-col"><option value="">-- Column --</option>'; | |||
| var laneSel = '<select class="form-select form-select-sm" id="pick-lane"><option value="">-- Swim Lane --</option>'; | |||
| document.querySelectorAll('.kanban-col-header').forEach(function (el) { | |||
| colSel += '<option value="' + el.dataset.colId + '">' + el.querySelector('.col-label').textContent + '</option>'; | |||
| }); | |||
| document.querySelectorAll('.kanban-lane-header').forEach(function (el) { | |||
| laneSel += '<option value="' + el.dataset.laneId + '">' + el.querySelector('.lane-label').textContent + '</option>'; | |||
| }); | |||
| colSel += '</select>'; | |||
| laneSel += '</select>'; | |||
| picker.innerHTML = | |||
| '<div class="col"><label class="form-label small">Column</label>' + colSel + '</div>' + | |||
| '<div class="col"><label class="form-label small">Swim Lane</label>' + laneSel + '</div>'; | |||
| var first = document.getElementById('card-job-number').closest('.mb-3'); | |||
| modal.querySelector('.modal-body').insertBefore(picker, first); | |||
| document.getElementById('pick-col').addEventListener('change', function () { | |||
| colIdEl.value = this.value; | |||
| }); | |||
| document.getElementById('pick-lane').addEventListener('change', function () { | |||
| laneIdEl.value = this.value; | |||
| }); | |||
| } | |||
| /* ── Public API ──────────────────────────────────────────── */ | |||
| window.KanbanModal = { openCreate: openCreate, openEdit: openEdit }; | |||
| })(); | |||
| @@ -0,0 +1,235 @@ | |||
| /* kanban-settings.js — settings panel: add/rename/delete/reorder columns and lanes */ | |||
| (function () { | |||
| 'use strict'; | |||
| var boardId = KANBAN.boardId; | |||
| var panel = document.getElementById('settings-panel'); | |||
| var overlay = document.getElementById('settings-overlay'); | |||
| /* ── Panel open/close ────────────────────────────────────── */ | |||
| document.getElementById('btn-settings').addEventListener('click', openPanel); | |||
| document.getElementById('btn-close-settings').addEventListener('click', closePanel); | |||
| overlay.addEventListener('click', closePanel); | |||
| function openPanel() { | |||
| panel.classList.add('open'); | |||
| overlay.classList.remove('d-none'); | |||
| } | |||
| function closePanel() { | |||
| panel.classList.remove('open'); | |||
| overlay.classList.add('d-none'); | |||
| } | |||
| /* ── Helpers ─────────────────────────────────────────────── */ | |||
| function post(url, data, cb) { | |||
| var params = new URLSearchParams(); | |||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||
| fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) | |||
| .then(function (r) { return r.json(); }) | |||
| .then(cb) | |||
| .catch(function (e) { console.error(url, e); }); | |||
| } | |||
| function postJson(url, payload, cb) { | |||
| fetch(url, { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload) | |||
| }).then(function (r) { return r.json(); }).then(cb) | |||
| .catch(function (e) { console.error(url, e); }); | |||
| } | |||
| function collectOrder(listId) { | |||
| return Array.from(document.querySelectorAll('#' + listId + ' li')).map(function (li, idx) { | |||
| return { id: parseInt(li.dataset.id), position: idx }; | |||
| }); | |||
| } | |||
| function buildListItem(id, name, editClass, deleteClass, labelClass) { | |||
| var li = document.createElement('li'); | |||
| li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; | |||
| li.dataset.id = id; | |||
| li.innerHTML = | |||
| '<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>' + | |||
| '<span class="flex-grow-1 ' + labelClass + '">' + esc(name) + '</span>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-secondary ' + editClass + '" title="Rename"><i class="bi bi-pencil"></i></button>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>'; | |||
| return li; | |||
| } | |||
| function esc(s) { | |||
| return String(s) | |||
| .replace(/&/g, '&').replace(/</g, '<') | |||
| .replace(/>/g, '>').replace(/"/g, '"'); | |||
| } | |||
| /* ── Sortable reorder ─────────────────────────────────────── */ | |||
| function initSortable(listId, reorderUrl) { | |||
| var el = document.getElementById(listId); | |||
| Sortable.create(el, { | |||
| handle: '.drag-handle', | |||
| animation: 150, | |||
| onEnd: function () { | |||
| postJson(reorderUrl, collectOrder(listId), function (res) { | |||
| if (!res.ok) console.error('Reorder failed', res); | |||
| }); | |||
| } | |||
| }); | |||
| } | |||
| initSortable('col-list', '/columns/reorder'); | |||
| initSortable('lane-list', '/swimlanes/reorder'); | |||
| /* ═══════════════════════════════════════════════════════════ | |||
| COLUMNS | |||
| ═══════════════════════════════════════════════════════════ */ | |||
| /* ── Add column ───────────────────────────────────────────── */ | |||
| document.getElementById('btn-add-column').addEventListener('click', function () { | |||
| document.getElementById('col-add-form').classList.remove('d-none'); | |||
| document.getElementById('col-add-input').focus(); | |||
| }); | |||
| document.getElementById('btn-col-add-cancel').addEventListener('click', function () { | |||
| document.getElementById('col-add-form').classList.add('d-none'); | |||
| document.getElementById('col-add-input').value = ''; | |||
| }); | |||
| document.getElementById('btn-col-add-save').addEventListener('click', function () { | |||
| var name = document.getElementById('col-add-input').value.trim(); | |||
| if (!name) return; | |||
| post('/columns', { board_id: boardId, name: name }, function (res) { | |||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||
| document.getElementById('col-add-form').classList.add('d-none'); | |||
| document.getElementById('col-add-input').value = ''; | |||
| var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text'); | |||
| document.getElementById('col-list').appendChild(li); | |||
| bindColItem(li); | |||
| window.KanbanBoard.addColumn(res); | |||
| }); | |||
| }); | |||
| /* ── Bind edit/delete on existing column items ─────────────── */ | |||
| function bindColItem(li) { | |||
| li.querySelector('.btn-edit-col').addEventListener('click', function () { | |||
| startRename(li, '.col-label-text', function (newName, done) { | |||
| post('/columns/' + li.dataset.id, { name: newName }, function (res) { | |||
| if (res.ok) { | |||
| done(true); | |||
| window.KanbanBoard.renameColumn(li.dataset.id, newName); | |||
| } else { | |||
| done(false); | |||
| alert(res.error || 'Rename failed'); | |||
| } | |||
| }); | |||
| }); | |||
| }); | |||
| li.querySelector('.btn-delete-col').addEventListener('click', function () { | |||
| if (!confirm('Delete this column and all its cards?')) return; | |||
| post('/columns/' + li.dataset.id + '/delete', {}, function (res) { | |||
| if (res.ok) { | |||
| window.KanbanBoard.removeColumn(li.dataset.id); | |||
| li.remove(); | |||
| } else { | |||
| alert(res.error || 'Delete failed'); | |||
| } | |||
| }); | |||
| }); | |||
| } | |||
| document.querySelectorAll('#col-list li').forEach(bindColItem); | |||
| /* ═══════════════════════════════════════════════════════════ | |||
| SWIM LANES | |||
| ═══════════════════════════════════════════════════════════ */ | |||
| /* ── Add lane ─────────────────────────────────────────────── */ | |||
| document.getElementById('btn-add-lane').addEventListener('click', function () { | |||
| document.getElementById('lane-add-form').classList.remove('d-none'); | |||
| document.getElementById('lane-add-input').focus(); | |||
| }); | |||
| document.getElementById('btn-lane-add-cancel').addEventListener('click', function () { | |||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||
| document.getElementById('lane-add-input').value = ''; | |||
| }); | |||
| document.getElementById('btn-lane-add-save').addEventListener('click', function () { | |||
| var name = document.getElementById('lane-add-input').value.trim(); | |||
| if (!name) return; | |||
| post('/swimlanes', { board_id: boardId, name: name }, function (res) { | |||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||
| document.getElementById('lane-add-input').value = ''; | |||
| var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text'); | |||
| document.getElementById('lane-list').appendChild(li); | |||
| bindLaneItem(li); | |||
| window.KanbanBoard.addLane(res); | |||
| }); | |||
| }); | |||
| /* ── Bind edit/delete on existing lane items ──────────────── */ | |||
| function bindLaneItem(li) { | |||
| li.querySelector('.btn-edit-lane').addEventListener('click', function () { | |||
| startRename(li, '.lane-label-text', function (newName, done) { | |||
| post('/swimlanes/' + li.dataset.id, { name: newName }, function (res) { | |||
| if (res.ok) { | |||
| done(true); | |||
| window.KanbanBoard.renameLane(li.dataset.id, newName); | |||
| } else { | |||
| done(false); | |||
| alert(res.error || 'Rename failed'); | |||
| } | |||
| }); | |||
| }); | |||
| }); | |||
| li.querySelector('.btn-delete-lane').addEventListener('click', function () { | |||
| if (!confirm('Delete this swim lane and all its cards?')) return; | |||
| post('/swimlanes/' + li.dataset.id + '/delete', {}, function (res) { | |||
| if (res.ok) { | |||
| window.KanbanBoard.removeLane(li.dataset.id); | |||
| li.remove(); | |||
| } else { | |||
| alert(res.error || 'Delete failed'); | |||
| } | |||
| }); | |||
| }); | |||
| } | |||
| document.querySelectorAll('#lane-list li').forEach(bindLaneItem); | |||
| /* ── Inline rename helper ─────────────────────────────────── */ | |||
| function startRename(li, labelSel, saveCb) { | |||
| var span = li.querySelector(labelSel); | |||
| var oldName = span.textContent.trim(); | |||
| var input = document.createElement('input'); | |||
| input.type = 'text'; | |||
| input.className = 'form-control form-control-sm inline-rename flex-grow-1'; | |||
| input.value = oldName; | |||
| span.replaceWith(input); | |||
| input.focus(); | |||
| input.select(); | |||
| function commit() { | |||
| var newName = input.value.trim(); | |||
| if (!newName || newName === oldName) { | |||
| abort(); | |||
| return; | |||
| } | |||
| saveCb(newName, function (ok) { | |||
| var replacement = document.createElement('span'); | |||
| replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; | |||
| replacement.textContent = ok ? newName : oldName; | |||
| input.replaceWith(replacement); | |||
| }); | |||
| } | |||
| function abort() { | |||
| var replacement = document.createElement('span'); | |||
| replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; | |||
| replacement.textContent = oldName; | |||
| input.replaceWith(replacement); | |||
| } | |||
| input.addEventListener('blur', commit); | |||
| input.addEventListener('keydown', function (e) { | |||
| if (e.key === 'Enter') { e.preventDefault(); commit(); } | |||
| if (e.key === 'Escape') { e.preventDefault(); abort(); } | |||
| }); | |||
| } | |||
| })(); | |||
| @@ -0,0 +1,45 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use App\Controllers\AuthController; | |||
| use App\Controllers\BoardsController; | |||
| use App\Controllers\CardsController; | |||
| use App\Controllers\ColumnsController; | |||
| use App\Controllers\HomeController; | |||
| use App\Controllers\SwimLanesController; | |||
| // Home — redirects to /boards | |||
| $router->get('/', [HomeController::class, 'index']); | |||
| // Boards | |||
| $router->get('/boards', [BoardsController::class, 'index']); | |||
| $router->get('/boards/create', [BoardsController::class, 'create']); | |||
| $router->post('/boards', [BoardsController::class, 'store']); | |||
| $router->get('/board/{slug}', [BoardsController::class, 'show']); | |||
| $router->get('/board/{slug}/edit', [BoardsController::class, 'edit']); | |||
| $router->post('/board/{slug}/update', [BoardsController::class, 'update']); | |||
| $router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']); | |||
| // Cards (JSON API) | |||
| $router->post('/cards', [CardsController::class, 'store']); | |||
| $router->post('/cards/{id}/move', [CardsController::class, 'move']); | |||
| $router->post('/cards/{id}/delete', [CardsController::class, 'destroy']); | |||
| $router->post('/cards/{id}', [CardsController::class, 'update']); | |||
| // Columns (JSON API) — /columns/reorder MUST be before /columns/{id} | |||
| $router->post('/columns/reorder', [ColumnsController::class, 'reorder']); | |||
| $router->post('/columns/{id}/delete', [ColumnsController::class, 'destroy']); | |||
| $router->post('/columns/{id}', [ColumnsController::class, 'update']); | |||
| $router->post('/columns', [ColumnsController::class, 'store']); | |||
| // Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} | |||
| $router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); | |||
| $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | |||
| $router->post('/swimlanes/{id}', [SwimLanesController::class, 'update']); | |||
| $router->post('/swimlanes', [SwimLanesController::class, 'store']); | |||
| // Auth (Keycloak SSO) | |||
| $router->get('/auth/login', [AuthController::class, 'login']); | |||
| $router->get('/auth/callback', [AuthController::class, 'callback']); | |||
| $router->get('/auth/logout', [AuthController::class, 'logout']); | |||
| @@ -0,0 +1,18 @@ | |||
| # Scripts | |||
| This directory holds project PHP scripts that are meant to be run from the command line. | |||
| Examples: | |||
| ```bash | |||
| php scripts/migrate.php up | |||
| php scripts/migrate.php status | |||
| php scripts/migrate.php fresh --seed | |||
| php scripts/seed_employees.php 1000 | |||
| ``` | |||
| Guidelines: | |||
| - Put CLI-only PHP entrypoints here. | |||
| - Keep reusable logic in `core/`, `app/`, or `database/`. | |||
| - Let scripts stay thin and call into application classes or helper functions. | |||
| @@ -0,0 +1,105 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||
| $command = $argv[1] ?? 'help'; | |||
| $options = array_slice($argv, 2); | |||
| $manager = migration_manager(); | |||
| try { | |||
| switch ($command) { | |||
| case 'up': | |||
| $ran = $manager->runPending(); | |||
| if ($ran === []) { | |||
| echo "No pending migrations." . PHP_EOL; | |||
| exit(0); | |||
| } | |||
| foreach ($ran as $migration) { | |||
| echo "Migrated: {$migration}" . PHP_EOL; | |||
| } | |||
| echo 'Applied ' . count($ran) . ' migration(s).' . PHP_EOL; | |||
| exit(0); | |||
| case 'down': | |||
| $steps = isset($argv[2]) ? max(1, (int) $argv[2]) : 1; | |||
| $rolledBack = $manager->rollback($steps); | |||
| if ($rolledBack === []) { | |||
| echo "No applied migrations to roll back." . PHP_EOL; | |||
| exit(0); | |||
| } | |||
| foreach ($rolledBack as $migration) { | |||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||
| } | |||
| echo 'Rolled back ' . count($rolledBack) . ' migration(s).' . PHP_EOL; | |||
| exit(0); | |||
| case 'status': | |||
| $status = $manager->status(); | |||
| if ($status === []) { | |||
| echo "No migration files found." . PHP_EOL; | |||
| exit(0); | |||
| } | |||
| foreach ($status as $row) { | |||
| $state = $row['ran'] ? 'up' : 'pending'; | |||
| $ranAt = $row['ran_at'] ?? '-'; | |||
| echo str_pad($state, 10) . ' ' . $row['migration'] . ' ' . $ranAt . PHP_EOL; | |||
| } | |||
| exit(0); | |||
| case 'make': | |||
| case 'create': | |||
| $name = $argv[2] ?? ''; | |||
| if ($name === '') { | |||
| throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_projects_table'); | |||
| } | |||
| $path = $manager->make($name); | |||
| echo "Created migration: {$path}" . PHP_EOL; | |||
| exit(0); | |||
| case 'fresh': | |||
| $result = $manager->fresh(); | |||
| foreach ($result['rolled_back'] as $migration) { | |||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||
| } | |||
| foreach ($result['migrated'] as $migration) { | |||
| echo "Migrated: {$migration}" . PHP_EOL; | |||
| } | |||
| if (in_array('--seed', $options, true)) { | |||
| require __DIR__ . '/../database/seed_employees.php'; | |||
| seed_employees(1000, true); | |||
| } | |||
| echo "Fresh migration run complete." . PHP_EOL; | |||
| exit(0); | |||
| case 'help': | |||
| default: | |||
| echo "Migration CLI" . PHP_EOL; | |||
| echo "Usage:" . PHP_EOL; | |||
| echo " php scripts/migrate.php up" . PHP_EOL; | |||
| echo " php scripts/migrate.php down [steps]" . PHP_EOL; | |||
| echo " php scripts/migrate.php status" . PHP_EOL; | |||
| echo " php scripts/migrate.php make <name>" . PHP_EOL; | |||
| echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL; | |||
| exit(0); | |||
| } | |||
| } catch (Throwable $exception) { | |||
| fwrite(STDERR, $exception->getMessage() . PHP_EOL); | |||
| exit(1); | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| require_once __DIR__ . '/../database/seed_employees.php'; | |||
| $targetTotal = isset($argv[1]) ? max(1, (int) $argv[1]) : 1000; | |||
| seed_employees($targetTotal); | |||
| @@ -0,0 +1,145 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||
| use Core\App; | |||
| use Core\Database; | |||
| use Core\Dispatcher; | |||
| use Core\MigrationManager; | |||
| use Core\Request; | |||
| use Core\Router; | |||
| $tempMigrationPath = sys_get_temp_dir() . '/mvc_migrations_' . uniqid('', true); | |||
| mkdir($tempMigrationPath, 0777, true); | |||
| $migrationFile = $tempMigrationPath . '/20260509_120000_create_projects_table.php'; | |||
| file_put_contents($migrationFile, <<<'PHP' | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL)'); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| $database->execute('DROP TABLE IF EXISTS projects'); | |||
| } | |||
| }; | |||
| PHP | |||
| ); | |||
| $memoryDatabase = new Database([ | |||
| 'dsn' => 'sqlite::memory:', | |||
| 'options' => [ | |||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||
| ], | |||
| ]); | |||
| $migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath); | |||
| $ran = $migrationManager->runPending(); | |||
| if ($ran !== ['20260509_120000_create_projects_table.php']) { | |||
| echo "FAIL: migration manager did not apply the expected migration\n"; | |||
| exit(1); | |||
| } | |||
| $projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||
| if ($projectTable === null) { | |||
| echo "FAIL: migration up() did not create the projects table\n"; | |||
| exit(1); | |||
| } | |||
| $rolledBack = $migrationManager->rollback(); | |||
| if ($rolledBack !== ['20260509_120000_create_projects_table.php']) { | |||
| echo "FAIL: migration manager did not roll back the expected migration\n"; | |||
| exit(1); | |||
| } | |||
| $projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||
| if ($projectTableAfterRollback !== null) { | |||
| echo "FAIL: migration down() did not remove the projects table\n"; | |||
| exit(1); | |||
| } | |||
| $createdMigrationPath = $migrationManager->make('create_tasks_table'); | |||
| if (!file_exists($createdMigrationPath)) { | |||
| echo "FAIL: migration manager did not create a migration file\n"; | |||
| exit(1); | |||
| } | |||
| $router = new Router(); | |||
| $app = new App(); | |||
| (new MigrationManager(database(), __DIR__ . '/../database/migrations'))->runPending(); | |||
| require_once __DIR__ . '/../routes/web.php'; | |||
| $router->get('/hello/{name}', function (string $name) { | |||
| return 'Hello, ' . $name; | |||
| }); | |||
| $request = new Request([], [], [ | |||
| 'REQUEST_METHOD' => 'GET', | |||
| 'REQUEST_URI' => '/hello/Daniel', | |||
| ]); | |||
| $response = (new Dispatcher($router, $app))->dispatch($request); | |||
| if ($response->status() !== 200) { | |||
| echo "FAIL: expected status 200\n"; | |||
| exit(1); | |||
| } | |||
| if ($response->content() !== 'Hello, Daniel') { | |||
| echo "FAIL: unexpected response content\n"; | |||
| exit(1); | |||
| } | |||
| $employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [ | |||
| 'REQUEST_METHOD' => 'GET', | |||
| 'REQUEST_URI' => '/employees', | |||
| ])); | |||
| if ($employeePage->status() !== 200) { | |||
| echo "FAIL: expected employee page status 200\n"; | |||
| exit(1); | |||
| } | |||
| if (strpos($employeePage->content(), 'Add Employee') === false) { | |||
| echo "FAIL: employee page did not render form content\n"; | |||
| exit(1); | |||
| } | |||
| $employeeData = (new Dispatcher($router, $app))->dispatch(new Request([ | |||
| 'search' => '', | |||
| ], [], [ | |||
| 'REQUEST_METHOD' => 'GET', | |||
| 'REQUEST_URI' => '/employees/data', | |||
| ])); | |||
| if ($employeeData->status() !== 200) { | |||
| echo "FAIL: expected employee data status 200\n"; | |||
| exit(1); | |||
| } | |||
| if (strpos($employeeData->content(), '[') === false) { | |||
| echo "FAIL: employee data endpoint did not return JSON array content\n"; | |||
| exit(1); | |||
| } | |||
| echo "PASS: migration manager and route dispatch work\n"; | |||
Powered by TurnKey Linux.