From 5046c2af118580668641f667cb49153091c9458d Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Sat, 9 May 2026 16:25:53 -0400 Subject: [PATCH] init --- .gitignore | 20 + AGENTS.md | 907 ++++++++++++++++++ app/Controllers/EmployeeController.php | 208 ++++ app/Controllers/HomeController.php | 32 + app/Models/Employee.php | 16 + app/Models/User.php | 12 + app/Repositories/EmployeeRepository.php | 122 +++ app/Repositories/UserRepository.php | 21 + app/ViewModels/EmployeeFormViewModel.php | 50 + app/ViewModels/HomeIndexViewModel.php | 13 + app/Views/employees/create.php | 54 ++ app/Views/employees/partials/form.php | 83 ++ app/Views/employees/partials/summary.php | 34 + app/Views/home/index.php | 39 + app/Views/layouts/app.php | 14 + app/Views/partials/footer.php | 9 + app/Views/partials/header.php | 48 + composer.json | 22 + composer.lock | 18 + config/database.php | 11 + core/App.php | 57 ++ core/Controller.php | 35 + core/Database.php | 49 + core/Dispatcher.php | 53 + core/Migration.php | 12 + core/MigrationManager.php | 289 ++++++ core/Repository.php | 38 + core/Request.php | 70 ++ core/Response.php | 64 ++ core/Route.php | 50 + core/Router.php | 39 + core/Validator.php | 52 + core/View.php | 68 ++ core/helpers.php | 134 +++ ...20260509_000001_create_employees_table.php | 30 + database/seed_employees.php | 107 +++ docs/README.md | 72 ++ migrate.php | 105 ++ public/.htaccess | 5 + public/css/site.css | 791 +++++++++++++++ public/index.php | 23 + public/js/app.js | 65 ++ routes/web.php | 14 + tests/run.php | 145 +++ 44 files changed, 4100 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 app/Controllers/EmployeeController.php create mode 100644 app/Controllers/HomeController.php create mode 100644 app/Models/Employee.php create mode 100644 app/Models/User.php create mode 100644 app/Repositories/EmployeeRepository.php create mode 100644 app/Repositories/UserRepository.php create mode 100644 app/ViewModels/EmployeeFormViewModel.php create mode 100644 app/ViewModels/HomeIndexViewModel.php create mode 100644 app/Views/employees/create.php create mode 100644 app/Views/employees/partials/form.php create mode 100644 app/Views/employees/partials/summary.php create mode 100644 app/Views/home/index.php create mode 100644 app/Views/layouts/app.php create mode 100644 app/Views/partials/footer.php create mode 100644 app/Views/partials/header.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/database.php create mode 100644 core/App.php create mode 100644 core/Controller.php create mode 100644 core/Database.php create mode 100644 core/Dispatcher.php create mode 100644 core/Migration.php create mode 100644 core/MigrationManager.php create mode 100644 core/Repository.php create mode 100644 core/Request.php create mode 100644 core/Response.php create mode 100644 core/Route.php create mode 100644 core/Router.php create mode 100644 core/Validator.php create mode 100644 core/View.php create mode 100644 core/helpers.php create mode 100644 database/migrations/20260509_000001_create_employees_table.php create mode 100644 database/seed_employees.php create mode 100644 docs/README.md create mode 100644 migrate.php create mode 100644 public/.htaccess create mode 100644 public/css/site.css create mode 100644 public/index.php create mode 100644 public/js/app.js create mode 100644 routes/web.php create mode 100644 tests/run.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..290c83d --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/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 +docker-compose.yml +Dockerfile diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8aa6a62 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,907 @@ +# AGENT.md — PHP Coding Standard + +This file defines the coding standards and working rules for AI agents and developers contributing to this PHP codebase. It is based on the principles from **PHP: The Right Way** and adapted into practical project instructions. + +Source reference: https://phptherightway.com/ + +--- + +## 1. 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**: routing, controllers, services, models, persistence, templates, and configuration should not be mixed together. + +PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style. + +--- + +## 2. PHP Version Standard + +Use the current stable PHP version supported by the project. + +Default expectation: + +```text +PHP 8.x+ +``` + +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" + } +} +``` + +--- + +## 3. 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. + +Use: + +```php +class InvoiceRepository +{ + public function findByCustomerId(int $customerId): array + { + // ... + } +} +``` + +Avoid unclear abbreviations: + +```php +class InvRepo +{ + public function fbcid($cid) + { + // ... + } +} +``` + +### Formatting Rules + +- Use `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. + +--- + +## 8. 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. + +--- + +## 9. Database Access + +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. + +Transaction example: + +```php +$pdo->beginTransaction(); + +try { + $orders->create($order); + $auditLog->record('order.created', $order->id()); + $pdo->commit(); +} catch (Throwable $e) { + $pdo->rollBack(); + throw $e; +} +``` + +--- + +## 10. Input Validation and Output Escaping + +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 + +### Validate on Input + +Example: + +```php +$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL); + +if ($email === false || $email === null) { + throw new InvalidArgumentException('A valid email address is required.'); +} +``` + +### Escape on Output + +For HTML output: + +```php +function e(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} +``` + +Usage: + +```php +

name()) ?>

+``` + +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 when appropriate. +- 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. + +--- + +## 11. 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. + +--- + +## 12. 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. + +--- + +## 13. 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/ +``` + +Example `.env.example`: + +```text +APP_ENV=local +APP_DEBUG=true +DATABASE_URL=mysql://user:password@localhost:3306/app +``` + +--- + +## 14. Error Handling and Logging + +Use exceptions for exceptional failure paths. + +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.'; +} +``` + +--- + +## 15. 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 when it fits the project. + +Plain PHP template example: + +```php +

+ + +``` + +--- + +## 16. 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: + +```php +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + exit('Method Not Allowed'); +} +``` + +--- + +## 17. 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`. + +--- + +## 18. Testing Standard + +Automated tests are expected for new behavior. + +Preferred tools: + +- PHPUnit +- Pest, if the project already uses it + +Rules: + +- Add or update tests with every 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. + +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); + } +} +``` + +Run tests: + +```bash +vendor/bin/phpunit +``` + +--- + +## 19. 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. + +--- + +## 20. Documentation + +Use PHPDoc where it adds clarity, especially for arrays, generics-like structures, complex return values, and public APIs. + +Good: + +```php +/** + * @return list + */ +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. + +--- + +## 21. 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. + +--- + +## 22. 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. +10. Leave the repository better organized than it was found. + +--- + +## 23. 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. + +--- + +## 24. 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 + +--- + +## 25. 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. + +--- + +## 26. 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 +``` + +--- + +## 27. 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 + + +## Project Overview + +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, or another large framework. + +## Tech Stack + +- PHP 8.2+ +- Composer +- PSR-4 autoloading +- PDO +- PHP views +- Optional SQLite/MySQL/SQL Server through PDO + +## 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 +``` + +## 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. + +## Request Flow + +Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response \ No newline at end of file diff --git a/app/Controllers/EmployeeController.php b/app/Controllers/EmployeeController.php new file mode 100644 index 0000000..6ff7eae --- /dev/null +++ b/app/Controllers/EmployeeController.php @@ -0,0 +1,208 @@ +buildViewModel((string) $request->input('search', '')); + $viewModel->saved = $request->input('saved') === '1'; + + return $this->view('employees.create', [ + 'model' => $viewModel, + 'pageTitle' => $viewModel->title, + ]); + } + + public function store() + { + $request = Request::capture(); + $form = $this->sanitizeFormData($request); + $errors = $this->validateForm($form, $request); + + if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) { + $errors['email'][] = 'That email address is already in use.'; + } + + if (!empty($errors)) { + $viewModel = $this->buildViewModel(); + $viewModel->form = $form; + $viewModel->errors = $errors; + + if ($this->isHtmxRequest($request)) { + return $this->fragment('employees.partials.form', [ + 'model' => $viewModel, + ]); + } + + return $this->view('employees.create', [ + 'model' => $viewModel, + 'pageTitle' => $viewModel->title, + ]); + } + + $employee = new Employee(); + $employee->firstName = $form['first_name']; + $employee->lastName = $form['last_name']; + $employee->email = $form['email']; + $employee->department = $form['department']; + $employee->jobTitle = $form['job_title']; + $employee->startDate = $form['start_date']; + + $this->employees()->create($employee); + + if ($this->isHtmxRequest($request)) { + $viewModel = $this->buildViewModel(); + $viewModel->saved = true; + + return $this->fragment('employees.partials.form', [ + 'model' => $viewModel, + ], 200, [ + 'HX-Trigger' => json_encode(['employees-changed' => true]), + ]); + } + + return $this->redirect('/employees?saved=1'); + } + + public function create() + { + return $this->redirect('/employees'); + } + + public function summary() + { + $request = Request::capture(); + $viewModel = $this->buildViewModel((string) $request->input('search', '')); + + return $this->fragment('employees.partials.summary', [ + 'model' => $viewModel, + ]); + } + + public function data() + { + $request = Request::capture(); + $search = trim((string) $request->input('search', '')); + $rows = $this->employees()->search($search); + + $data = array_map( + static function (array $row): array { + return [ + 'id' => (int) $row['id'], + 'full_name' => trim($row['first_name'] . ' ' . $row['last_name']), + 'first_name' => (string) $row['first_name'], + 'last_name' => (string) $row['last_name'], + 'email' => (string) $row['email'], + 'department' => (string) $row['department'], + 'job_title' => (string) $row['job_title'], + 'start_date' => (string) $row['start_date'], + 'created_at' => (string) $row['created_at'], + ]; + }, + $rows + ); + + return $this->json($data); + } + + /** + * @return array + */ + private function sanitizeFormData(Request $request): array + { + return [ + 'first_name' => trim((string) $request->input('first_name', '')), + 'last_name' => trim((string) $request->input('last_name', '')), + 'email' => trim((string) $request->input('email', '')), + 'department' => trim((string) $request->input('department', '')), + 'job_title' => trim((string) $request->input('job_title', '')), + 'start_date' => trim((string) $request->input('start_date', '')), + ]; + } + + /** + * @param array $form + * @return array> + */ + private function validateForm(array $form, Request $request): array + { + $validator = new Validator(); + + $validator + ->required('first_name', $form['first_name'], 'First name is required.') + ->maxLength('first_name', $form['first_name'], 100, 'First name must be 100 characters or fewer.') + ->required('last_name', $form['last_name'], 'Last name is required.') + ->maxLength('last_name', $form['last_name'], 100, 'Last name must be 100 characters or fewer.') + ->required('email', $form['email'], 'Email is required.') + ->maxLength('email', $form['email'], 255, 'Email must be 255 characters or fewer.') + ->required('department', $form['department'], 'Department is required.') + ->maxLength('department', $form['department'], 100, 'Department must be 100 characters or fewer.') + ->required('job_title', $form['job_title'], 'Job title is required.') + ->maxLength('job_title', $form['job_title'], 150, 'Job title must be 150 characters or fewer.') + ->required('start_date', $form['start_date'], 'Start date is required.'); + + $errors = $validator->errors(); + + if (!verify_csrf_token((string) $request->input('_token', ''))) { + $errors['_token'][] = 'Your form session expired. Please refresh the page and try again.'; + } + + if ($form['email'] !== '' && filter_var($form['email'], FILTER_VALIDATE_EMAIL) === false) { + $errors['email'][] = 'Enter a valid email address.'; + } + + if ($form['start_date'] !== '' && !$this->isValidDate($form['start_date'])) { + $errors['start_date'][] = 'Enter a valid start date.'; + } + + return $errors; + } + + private function isValidDate(string $value): bool + { + $date = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + + return $date !== false && $date->format('Y-m-d') === $value; + } + + private function employees(): EmployeeRepository + { + return new EmployeeRepository(database()); + } + + private function isHtmxRequest(Request $request): bool + { + return strtolower((string) $request->server('HTTP_HX_REQUEST', '')) === 'true'; + } + + private function buildViewModel(string $search = ''): EmployeeFormViewModel + { + $viewModel = new EmployeeFormViewModel(); + $viewModel->search = trim($search); + + $employees = $this->employees()->search($viewModel->search); + $newestEmployee = $this->employees()->newestMatching($viewModel->search); + + $viewModel->employees = array_slice($employees, 0, 5); + $viewModel->newestEmployee = $newestEmployee; + $viewModel->summary = [ + 'employee_count' => $this->employees()->countMatching($viewModel->search), + 'department_count' => $this->employees()->countDepartments($viewModel->search), + 'latest_start_date' => $newestEmployee['start_date'] ?? 'N/A', + ]; + + return $viewModel; + } +} diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..230df0d --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,32 @@ +title = 'MindVisionCode PHP'; + $model->eyebrow = 'Small MVC framework'; + $model->message = 'A lightweight PHP MVC starter with a central dispatcher, clean controllers, SQLite-backed repositories, and readable conventions.'; + $model->routeExample = '/employees'; + + return $this->view('home.index', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + + public function user(string $id) + { + return $this->json([ + 'userId' => $id, + ]); + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php new file mode 100644 index 0000000..6418db5 --- /dev/null +++ b/app/Models/Employee.php @@ -0,0 +1,16 @@ +database->execute( + 'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date) + VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)', + [ + 'first_name' => $employee->firstName, + 'last_name' => $employee->lastName, + 'email' => $employee->email, + 'department' => $employee->department, + 'job_title' => $employee->jobTitle, + 'start_date' => $employee->startDate, + ] + ); + } + + public function findByEmail(string $email): ?array + { + return $this->database->first( + 'SELECT * FROM employees WHERE email = :email', + ['email' => $email] + ); + } + + /** + * @return list> + */ + public function latest(int $limit = 8): array + { + $limit = max(1, $limit); + + return $this->database->query( + "SELECT * FROM employees ORDER BY created_at DESC, id DESC LIMIT {$limit}" + ); + } + + /** + * @return list> + */ + public function search(string $search = ''): array + { + [$whereClause, $parameters] = $this->buildSearchClause($search); + + return $this->database->query( + 'SELECT id, first_name, last_name, email, department, job_title, start_date, created_at + FROM employees' . $whereClause . ' + ORDER BY created_at DESC, id DESC', + $parameters + ); + } + + public function countMatching(string $search = ''): int + { + [$whereClause, $parameters] = $this->buildSearchClause($search); + $row = $this->database->first( + 'SELECT COUNT(*) AS total FROM employees' . $whereClause, + $parameters + ); + + return (int) ($row['total'] ?? 0); + } + + public function countDepartments(string $search = ''): int + { + [$whereClause, $parameters] = $this->buildSearchClause($search); + $row = $this->database->first( + 'SELECT COUNT(DISTINCT department) AS total FROM employees' . $whereClause, + $parameters + ); + + return (int) ($row['total'] ?? 0); + } + + public function newestMatching(string $search = ''): ?array + { + [$whereClause, $parameters] = $this->buildSearchClause($search); + + return $this->database->first( + 'SELECT id, first_name, last_name, department, job_title, start_date + FROM employees' . $whereClause . ' + ORDER BY created_at DESC, id DESC + LIMIT 1', + $parameters + ); + } + + /** + * @return array{0:string,1:array} + */ + private function buildSearchClause(string $search): array + { + $search = trim($search); + + if ($search === '') { + return ['', []]; + } + + return [ + ' WHERE first_name LIKE :search + OR last_name LIKE :search + OR email LIKE :search + OR department LIKE :search + OR job_title LIKE :search + OR start_date LIKE :search', + ['search' => '%' . $search . '%'], + ]; + } +} diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php new file mode 100644 index 0000000..a2e0d70 --- /dev/null +++ b/app/Repositories/UserRepository.php @@ -0,0 +1,21 @@ +database->first( + 'SELECT * FROM users WHERE email = :email', + ['email' => $email] + ); + } +} diff --git a/app/ViewModels/EmployeeFormViewModel.php b/app/ViewModels/EmployeeFormViewModel.php new file mode 100644 index 0000000..18800e2 --- /dev/null +++ b/app/ViewModels/EmployeeFormViewModel.php @@ -0,0 +1,50 @@ + + */ + public array $form = [ + 'first_name' => '', + 'last_name' => '', + 'email' => '', + 'department' => '', + 'job_title' => '', + 'start_date' => '', + ]; + + /** + * @var array> + */ + public array $errors = []; + + /** + * @var list> + */ + public array $employees = []; + + /** + * @var array + */ + public array $summary = [ + 'employee_count' => 0, + 'department_count' => 0, + 'latest_start_date' => 'N/A', + ]; + + /** + * @var array|null + */ + public ?array $newestEmployee = null; +} diff --git a/app/ViewModels/HomeIndexViewModel.php b/app/ViewModels/HomeIndexViewModel.php new file mode 100644 index 0000000..458b11f --- /dev/null +++ b/app/ViewModels/HomeIndexViewModel.php @@ -0,0 +1,13 @@ + +
+ eyebrow) ?> +

title) ?>

+

intro) ?>

+
+ +
+
+
+

Employee Workspace

+

Use htmx for server updates, Alpine for page state, and Tabulator for a richer table experience.

+
+ + +
+ +
+ +
+
+ +
+
+ +
+ +
+
+

Employee Directory Table

+

Browse, search, and sort all employees in one place. The table refreshes after new employee records are saved.

+
+ +
+ HTMX + Alpine + Tabulator + Live data endpoint: /employees/data +
+ +
+
+
+ diff --git a/app/Views/employees/partials/form.php b/app/Views/employees/partials/form.php new file mode 100644 index 0000000..00d2b50 --- /dev/null +++ b/app/Views/employees/partials/form.php @@ -0,0 +1,83 @@ +
+
+

Add Employee

+

Store a clean employee record with basic contact and role details.

+
+ + saved): ?> +
+ Employee information was saved to SQLite successfully. +
+ + + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ + + + + + + + + + + +
+ +
+ + Saving employee... +
+
+
diff --git a/app/Views/employees/partials/summary.php b/app/Views/employees/partials/summary.php new file mode 100644 index 0000000..57b4999 --- /dev/null +++ b/app/Views/employees/partials/summary.php @@ -0,0 +1,34 @@ +
+

Live Summary

+

Server-rendered fragments refresh here with htmx whenever employees change or search terms update.

+
+ +
+
+ Total Employees + summary['employee_count']) ?> +
+ +
+ Departments + summary['department_count']) ?> +
+ +
+ Latest Start Date + summary['latest_start_date']) ?> +
+
+ +newestEmployee !== null): ?> +
+ Newest matching record +

newestEmployee['first_name'] . ' ' . $model->newestEmployee['last_name']) ?>

+

newestEmployee['job_title']) ?> in newestEmployee['department']) ?>

+
+ +
+

No matching employees yet.

+

Try a broader search or add a new employee record.

+
+ diff --git a/app/Views/home/index.php b/app/Views/home/index.php new file mode 100644 index 0000000..9cc963f --- /dev/null +++ b/app/Views/home/index.php @@ -0,0 +1,39 @@ +
+
+ eyebrow) ?> +

title) ?>

+

message) ?>

+ + +
+ + +
+ +
+
+

Readable by design

+

Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.

+
+ +
+

Classic MVC feel

+

Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.

+
+ +
+

SQLite ready

+

Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.

+
+
diff --git a/app/Views/layouts/app.php b/app/Views/layouts/app.php new file mode 100644 index 0000000..5796585 --- /dev/null +++ b/app/Views/layouts/app.php @@ -0,0 +1,14 @@ + + +
+
+ +
+
+ + diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php new file mode 100644 index 0000000..d2a38dd --- /dev/null +++ b/app/Views/partials/footer.php @@ -0,0 +1,9 @@ +
+ +
+ + + diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php new file mode 100644 index 0000000..7844f3a --- /dev/null +++ b/app/Views/partials/header.php @@ -0,0 +1,48 @@ + 'Home', 'href' => '/'], + ['label' => 'Employees', 'href' => '/employees'], + ['label' => 'Example JSON', 'href' => '/users/123'], +]; + +$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); +$currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; +?> + + + + + + <?= e($pageTitle ?? 'MindVisionCode PHP') ?> + + + + + + + + +
+ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..16fb728 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "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 migrate.php up", + "migrate:down": "php migrate.php down", + "migrate:status": "php migrate.php status", + "migrate:fresh": "php migrate.php fresh", + "migrate:fresh-seed": "php migrate.php fresh --seed" + }, + "require": {} +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..75912c3 --- /dev/null +++ b/composer.lock @@ -0,0 +1,18 @@ +{ + "_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": "a9e5ff0daf78f24b652c32b38b47d81b", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..a7bea57 --- /dev/null +++ b/config/database.php @@ -0,0 +1,11 @@ + 'sqlite:' . __DIR__ . '/../database/app.sqlite', + 'username' => null, + 'password' => null, + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; diff --git a/core/App.php b/core/App.php new file mode 100644 index 0000000..2ac8b9f --- /dev/null +++ b/core/App.php @@ -0,0 +1,57 @@ +bindings[$name] = $value; + } + + public function get(string $name): mixed + { + return $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(array_values($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, array_values($parameters)); + } +} diff --git a/core/Controller.php b/core/Controller.php new file mode 100644 index 0000000..a73032e --- /dev/null +++ b/core/Controller.php @@ -0,0 +1,35 @@ +method() !== 'POST') { + throw new \Exception('This action requires POST.'); + } + } +} diff --git a/core/Database.php b/core/Database.php new file mode 100644 index 0000000..5ee020d --- /dev/null +++ b/core/Database.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/core/Dispatcher.php b/core/Dispatcher.php new file mode 100644 index 0000000..16ff518 --- /dev/null +++ b/core/Dispatcher.php @@ -0,0 +1,53 @@ +router = $router; + $this->app = $app; + } + + 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) { + return Response::serverError($e->getMessage()); + } 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); + } +} diff --git a/core/Migration.php b/core/Migration.php new file mode 100644 index 0000000..4bc46ee --- /dev/null +++ b/core/Migration.php @@ -0,0 +1,12 @@ +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( + 'DELETE FROM migrations + WHERE id NOT IN ( + SELECT MIN(id) + FROM migrations + GROUP BY migration + )' + ); + + $this->database->execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' + ); + + $files = array_map('basename', $this->migrationFiles()); + + if ($files === []) { + $this->database->execute('DELETE FROM migrations'); + return; + } + + $placeholders = []; + $parameters = []; + + foreach ($files as $index => $file) { + $placeholder = 'migration_' . $index; + $placeholders[] = ':' . $placeholder; + $parameters[$placeholder] = $file; + } + + $this->database->execute( + 'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')', + $parameters + ); + } + + 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->pdo()->beginTransaction(); + + try { + $migration->up($this->database); + + $this->database->execute( + 'INSERT OR IGNORE INTO migrations (migration) VALUES (:migration)', + ['migration' => $name] + ); + + $this->database->pdo()->commit(); + $ranMigrations[] = $name; + } catch (\Throwable $exception) { + $this->database->pdo()->rollBack(); + throw $exception; + } + } + + return $ranMigrations; + } + + public function rollback(int $steps = 1): array + { + $this->ensureTable(); + + $steps = max(1, $steps); + $applied = $this->database->query( + "SELECT MAX(id) AS id, migration + FROM migrations + GROUP BY migration + ORDER BY id DESC + LIMIT {$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->pdo()->beginTransaction(); + + try { + $migration->down($this->database); + + $this->database->execute( + 'DELETE FROM migrations WHERE id = :id', + ['id' => $row['id']] + ); + + $this->database->pdo()->commit(); + $rolledBack[] = $row['migration']; + } catch (\Throwable $exception) { + $this->database->pdo()->rollBack(); + throw $exception; + } + } + + 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->pdo()->beginTransaction(); + + try { + $migration->down($this->database); + $this->database->pdo()->commit(); + $rolledBack[] = $name; + } catch (\Throwable $exception) { + $this->database->pdo()->rollBack(); + throw $exception; + } + } + + $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 = <<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.'); + } +} diff --git a/core/Repository.php b/core/Repository.php new file mode 100644 index 0000000..4c01c77 --- /dev/null +++ b/core/Repository.php @@ -0,0 +1,38 @@ +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] + ); + } +} diff --git a/core/Request.php b/core/Request.php new file mode 100644 index 0000000..c04f7e8 --- /dev/null +++ b/core/Request.php @@ -0,0 +1,70 @@ +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 + { + return strtoupper($this->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); + } +} diff --git a/core/Response.php b/core/Response.php new file mode 100644 index 0000000..afd3a0c --- /dev/null +++ b/core/Response.php @@ -0,0 +1,64 @@ +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), + $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; + } +} diff --git a/core/Route.php b/core/Route.php new file mode 100644 index 0000000..9d4a8c4 --- /dev/null +++ b/core/Route.php @@ -0,0 +1,50 @@ +method = strtoupper($method); + $this->path = $path; + $this->handler = $handler; + } + + public function matches(string $method, string $path): bool + { + if (strtoupper($method) !== $this->method) { + return false; + } + + $routePattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $this->path); + $routePattern = '#^' . $routePattern . '$#'; + + if (!preg_match($routePattern, $path, $matches)) { + return false; + } + + array_shift($matches); + preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $this->path, $names); + + $this->parameters = []; + + foreach ($names[1] as $index => $name) { + $this->parameters[$name] = $matches[$index] ?? null; + } + + return true; + } + + public function dispatch(App $app): mixed + { + return $app->call($this->handler, $this->parameters); + } +} diff --git a/core/Router.php b/core/Router.php new file mode 100644 index 0000000..014250d --- /dev/null +++ b/core/Router.php @@ -0,0 +1,39 @@ +add('GET', $path, $handler); + } + + public function post(string $path, callable|array|string $handler): Route + { + return $this->add('POST', $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; + } +} diff --git a/core/Validator.php b/core/Validator.php new file mode 100644 index 0000000..3e6f340 --- /dev/null +++ b/core/Validator.php @@ -0,0 +1,52 @@ +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 passes(): bool + { + return empty($this->errors); + } + + public function fails(): bool + { + return !$this->passes(); + } + + public function errors(): array + { + return $this->errors; + } +} diff --git a/core/View.php b/core/View.php new file mode 100644 index 0000000..c823d3a --- /dev/null +++ b/core/View.php @@ -0,0 +1,68 @@ +title) && + trim($data['model']->title) !== '' + ) { + return $data['model']->title; + } + + return 'MindVisionCode PHP'; + } +} diff --git a/core/helpers.php b/core/helpers.php new file mode 100644 index 0000000..fffc7cb --- /dev/null +++ b/core/helpers.php @@ -0,0 +1,134 @@ + $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 ''; +} + +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); +} diff --git a/database/migrations/20260509_000001_create_employees_table.php b/database/migrations/20260509_000001_create_employees_table.php new file mode 100644 index 0000000..2ac3812 --- /dev/null +++ b/database/migrations/20260509_000001_create_employees_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/seed_employees.php b/database/seed_employees.php new file mode 100644 index 0000000..82317e3 --- /dev/null +++ b/database/seed_employees.php @@ -0,0 +1,107 @@ +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); +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f45d86c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,72 @@ +# MindVisionCode PHP + +A small PHP MVC framework inspired by a Classic ASP MVC framework. + +## Run + +```bash +composer install +php 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 + +## 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 migrate.php up +php migrate.php down +php migrate.php status +php migrate.php make create_projects_table +php migrate.php fresh +php migrate.php fresh --seed +``` + +## 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 diff --git a/migrate.php b/migrate.php new file mode 100644 index 0000000..6a4f182 --- /dev/null +++ b/migrate.php @@ -0,0 +1,105 @@ +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 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 migrate.php up" . PHP_EOL; + echo " php migrate.php down [steps]" . PHP_EOL; + echo " php migrate.php status" . PHP_EOL; + echo " php migrate.php make " . PHP_EOL; + echo " php migrate.php fresh [--seed]" . PHP_EOL; + exit(0); + } +} catch (Throwable $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + exit(1); +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..8661356 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,5 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/public/css/site.css b/public/css/site.css new file mode 100644 index 0000000..f0e1fa1 --- /dev/null +++ b/public/css/site.css @@ -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; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9d26c73 --- /dev/null +++ b/public/index.php @@ -0,0 +1,23 @@ +dispatch($request); + +$response->send(); diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..62af4cb --- /dev/null +++ b/public/js/app.js @@ -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, + }); + }, + }; +}; diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..44cc940 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,14 @@ +get('/', [HomeController::class, 'index']); +$router->get('/users/{id}', [HomeController::class, 'user']); +$router->get('/employees', [EmployeeController::class, 'index']); +$router->get('/employees/create', [EmployeeController::class, 'create']); +$router->get('/employees/summary', [EmployeeController::class, 'summary']); +$router->get('/employees/data', [EmployeeController::class, 'data']); +$router->post('/employees', [EmployeeController::class, 'store']); diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..97762d4 --- /dev/null +++ b/tests/run.php @@ -0,0 +1,145 @@ +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";