Commit 2e052b1f authored by Administrator's avatar Administrator

Update 4 files via Son of Anton

parent a83fd794
...@@ -6,33 +6,35 @@ namespace App\Core; ...@@ -6,33 +6,35 @@ namespace App\Core;
abstract class Controller abstract class Controller
{ {
protected Request $request; protected Request $request;
protected array $middleware = [];
public function __construct(Request $request) public function __construct()
{ {
$this->request = $request;
} }
protected function view(string $viewPath, array $data = []): Response protected function view(string $viewPath, array $data = []): Response
{ {
$template = new Template(); $template = new Template();
$html = $template->render($viewPath, $data); $html = $template->render($viewPath, $data);
return (new Response())->html($html);
$response = new Response();
return $response->html($html);
} }
protected function json($data, int $status = 200): Response protected function json($data, int $status = 200): Response
{ {
return (new Response())->json($data, $status); $response = new Response();
return $response->json($data, $status);
} }
protected function redirect(string $url): Response protected function redirect(string $url): Response
{ {
return (new Response())->redirect($url); return new Response($url);
} }
protected function back(): Response protected function back(): Response
{ {
return (new Response())->back(); $referer = $_SERVER['HTTP_REFERER'] ?? '/';
return new Response($referer);
} }
protected function validate(array $data, array $rules): array protected function validate(array $data, array $rules): array
...@@ -41,46 +43,29 @@ abstract class Controller ...@@ -41,46 +43,29 @@ abstract class Controller
$result = $validator->validate($data, $rules); $result = $validator->validate($data, $rules);
if ($result->fails()) { if ($result->fails()) {
if ($this->request->isAjax() || $this->request->isJson()) { $session = App::getInstance()->session();
$response = (new Response())->json([ $session->flash('_old_input', $data);
'success' => false, $errors = $result->errors();
'errors' => $result->errors(), $alerts = [];
], 422); foreach ($errors as $field => $fieldErrors) {
$response->send(); foreach ($fieldErrors as $error) {
exit; $alerts[] = ['type' => 'error', 'message' => $error];
}
} }
$session->flash('_alerts', $alerts);
App::getInstance()->session()->flash('_errors', $result->errors());
App::getInstance()->session()->flash('_old_input', $data);
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
$response = (new Response())->redirect($referer);
$response->send();
exit;
} }
return $result->validated(); return $data;
} }
protected function authorize(string $permission): void protected function authorize(string $permission): void
{ {
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
if (!$employee) { if (!$employee) {
$response = (new Response())->redirect('/login'); throw new \RuntimeException('Unauthorized', 401);
$response->send();
exit;
} }
if (method_exists($employee, 'hasPermission') && !$employee->hasPermission($permission)) { if (method_exists($employee, 'hasPermission') && !$employee->hasPermission($permission)) {
$response = (new Response())->html( throw new \RuntimeException('Forbidden', 403);
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>غير مصرح</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:60px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:48px;}p{color:#6B7280;}</style></head>'
. '<body><h1>403</h1><p>غير مصرح لك بالوصول لهذه الصفحة</p><a href="/">العودة للرئيسية</a></body></html>',
403
);
$response->send();
exit;
} }
} }
......
...@@ -5,93 +5,59 @@ namespace App\Core; ...@@ -5,93 +5,59 @@ namespace App\Core;
final class Response final class Response
{ {
private string $body = '';
private int $statusCode = 200; private int $statusCode = 200;
private array $headers = []; private array $headers = [];
private string $body = ''; private ?string $redirectUrl = null;
private bool $sent = false; private array $flashData = [];
public function __construct(?string $redirectUrl = null)
{
if ($redirectUrl !== null) {
$this->redirectUrl = $redirectUrl;
$this->statusCode = 302;
}
}
public function html(string $content, int $status = 200): self public function html(string $content, int $status = 200): self
{ {
$this->statusCode = $status;
$this->headers['Content-Type'] = 'text/html; charset=utf-8';
$this->body = $content; $this->body = $content;
$this->statusCode = $status;
$this->headers['Content-Type'] = 'text/html; charset=UTF-8';
return $this; return $this;
} }
public function json($data, int $status = 200): self public function json($data, int $status = 200): self
{ {
$this->statusCode = $status;
$this->headers['Content-Type'] = 'application/json; charset=utf-8';
$this->body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $this->body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$this->statusCode = $status;
$this->headers['Content-Type'] = 'application/json; charset=UTF-8';
return $this; return $this;
} }
public function redirect(string $url, int $status = 302): self public function redirect(string $url, int $status = 302): self
{ {
$this->redirectUrl = $url;
$this->statusCode = $status; $this->statusCode = $status;
$this->headers['Location'] = $url;
$this->body = '';
return $this;
}
public function back(): self
{
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
return $this->redirect($referer);
}
public function download(string $filePath, string $filename): self
{
if (!file_exists($filePath)) {
return $this->html('File not found', 404);
}
$this->statusCode = 200;
$this->headers['Content-Type'] = 'application/octet-stream';
$this->headers['Content-Disposition'] = 'attachment; filename="' . $filename . '"';
$this->headers['Content-Length'] = (string) filesize($filePath);
$this->body = file_get_contents($filePath);
return $this;
}
public function withSession(string $key, $value): self
{
App::getInstance()->session()->flash($key, $value);
return $this;
}
public function withError(string $message): self
{
$session = App::getInstance()->session();
$alerts = $session->get('_alerts', []);
$alerts[] = ['type' => 'error', 'message' => $message];
$session->flash('_alerts', $alerts);
return $this; return $this;
} }
public function withSuccess(string $message): self public function withSuccess(string $message): self
{ {
$session = App::getInstance()->session(); $session = App::getInstance()->session();
$alerts = $session->get('_alerts', []); $alerts = $session->get('_flash._alerts', []);
if (!is_array($alerts)) $alerts = [];
$alerts[] = ['type' => 'success', 'message' => $message]; $alerts[] = ['type' => 'success', 'message' => $message];
$session->flash('_alerts', $alerts); $session->flash('_alerts', $alerts);
return $this; return $this;
} }
public function withWarning(string $message): self public function withError(string $message): self
{
$session = App::getInstance()->session();
$alerts = $session->get('_alerts', []);
$alerts[] = ['type' => 'warning', 'message' => $message];
$session->flash('_alerts', $alerts);
return $this;
}
public function withInfo(string $message): self
{ {
$session = App::getInstance()->session(); $session = App::getInstance()->session();
$alerts = $session->get('_alerts', []); $alerts = $session->get('_flash._alerts', []);
$alerts[] = ['type' => 'info', 'message' => $message]; if (!is_array($alerts)) $alerts = [];
$alerts[] = ['type' => 'error', 'message' => $message];
$session->flash('_alerts', $alerts); $session->flash('_alerts', $alerts);
return $this; return $this;
} }
...@@ -102,9 +68,13 @@ final class Response ...@@ -102,9 +68,13 @@ final class Response
return $this; return $this;
} }
public function withErrors(array $errors): self public function withWarning(string $message): self
{ {
App::getInstance()->session()->flash('_errors', $errors); $session = App::getInstance()->session();
$alerts = $session->get('_flash._alerts', []);
if (!is_array($alerts)) $alerts = [];
$alerts[] = ['type' => 'warning', 'message' => $message];
$session->flash('_alerts', $alerts);
return $this; return $this;
} }
...@@ -120,37 +90,40 @@ final class Response ...@@ -120,37 +90,40 @@ final class Response
return $this; return $this;
} }
public function pdf(string $html): self public function download(string $filePath, string $filename): self
{ {
$this->statusCode = 200; if (!file_exists($filePath)) {
$this->headers['Content-Type'] = 'text/html; charset=utf-8'; $this->statusCode = 404;
$this->body = $html; $this->body = 'File not found';
return $this;
}
$this->headers['Content-Type'] = mime_content_type($filePath) ?: 'application/octet-stream';
$this->headers['Content-Disposition'] = 'attachment; filename="' . $filename . '"';
$this->headers['Content-Length'] = (string) filesize($filePath);
$this->body = file_get_contents($filePath);
return $this; return $this;
} }
public function send(): void public function send(): void
{ {
if ($this->sent) { // Handle redirect
return; if ($this->redirectUrl !== null) {
http_response_code($this->statusCode);
header('Location: ' . $this->redirectUrl);
exit;
} }
// Set status code
http_response_code($this->statusCode); http_response_code($this->statusCode);
// Set headers
foreach ($this->headers as $key => $value) { foreach ($this->headers as $key => $value) {
header("{$key}: {$value}"); header("{$key}: {$value}");
} }
// Output body
echo $this->body; echo $this->body;
$this->sent = true; exit;
}
public function getBody(): string
{
return $this->body;
}
public function getStatusCode(): int
{
return $this->statusCode;
} }
} }
\ No newline at end of file
...@@ -5,133 +5,209 @@ namespace App\Core; ...@@ -5,133 +5,209 @@ namespace App\Core;
final class Template final class Template
{ {
private ?string $layoutPath = null; private string $basePath;
private array $sections = []; private array $sections = [];
private ?string $currentSection = null; private array $sectionStack = [];
private array $stacks = []; private ?string $layoutName = null;
private ?string $currentStack = null; private array $layoutData = [];
private array $data = [];
public function __construct()
{
$this->basePath = dirname(__DIR__, 2);
}
public function render(string $viewPath, array $data = []): string public function render(string $viewPath, array $data = []): string
{ {
$file = $this->resolveViewPath($viewPath); $this->data = $data;
$this->sections = [];
$this->sectionStack = [];
$this->layoutName = null;
$file = $this->resolvePath($viewPath);
if (!file_exists($file)) { if (!file_exists($file)) {
throw new \RuntimeException("View not found: {$viewPath} (looked at {$file})"); throw new \RuntimeException("View not found: {$viewPath} (looked in: {$file})");
} }
$data['app'] = App::getInstance(); // Render the view (which may call layout() and section())
$content = $this->renderFile($file, $data); $viewContent = $this->renderFile($file, $data);
if ($this->layoutPath !== null) { // If no layout was specified, return view content directly
$layoutFile = $this->resolveViewPath($this->layoutPath); if ($this->layoutName === null) {
if (!file_exists($layoutFile)) { return $viewContent;
throw new \RuntimeException("Layout not found: {$this->layoutPath}");
}
$this->sections['__content__'] = $content;
$content = $this->renderFile($layoutFile, $data);
$this->layoutPath = null;
} }
return $content; // Render the layout with sections
} $layoutFile = $this->resolvePath($this->layoutName);
if (!file_exists($layoutFile)) {
throw new \RuntimeException("Layout not found: {$this->layoutName} (looked in: {$layoutFile})");
}
private function renderFile(string $file, array $data): string // Store any non-sectioned content as 'content' if not already set
{ $trimmed = trim($viewContent);
extract($data); if ($trimmed !== '' && !isset($this->sections['content'])) {
$__template = $this; $this->sections['content'] = $viewContent;
ob_start(); }
require $file;
return ob_get_clean(); return $this->renderFile($layoutFile, $data);
} }
private function resolveViewPath(string $path): string private function renderFile(string $file, array $data = []): string
{ {
$base = Autoloader::basePath() . '/app/'; $__template = $this;
$parts = explode('.', $path); extract($data, EXTR_SKIP);
if ($parts[0] === 'Shared' || $parts[0] === 'Layout') { ob_start();
return $base . 'Shared/' . implode('/', $parts) . '.php'; try {
require $file;
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
} }
return ob_get_clean();
return $base . 'Modules/' . implode('/', $parts) . '.php';
} }
public function layout(string $layoutPath): void /**
* Called by views to specify which layout to use.
*/
public function layout(string $name): void
{ {
$this->layoutPath = $layoutPath; $this->layoutName = $name;
} }
/**
* Start a named section.
*/
public function section(string $name): void public function section(string $name): void
{ {
$this->currentSection = $name; $this->sectionStack[] = $name;
ob_start(); ob_start();
} }
/**
* End the current section.
*/
public function endSection(): void public function endSection(): void
{ {
if ($this->currentSection !== null) { if (empty($this->sectionStack)) {
$this->sections[$this->currentSection] = ob_get_clean(); throw new \RuntimeException('Cannot end section — no section started.');
$this->currentSection = null;
} }
$name = array_pop($this->sectionStack);
$content = ob_get_clean();
$this->sections[$name] = $content;
} }
/**
* Output a section's content (called by layouts).
* Returns the section content or default.
*/
public function yield(string $name, string $default = ''): string public function yield(string $name, string $default = ''): string
{ {
return $this->sections[$name] ?? $default; return $this->sections[$name] ?? $default;
} }
/**
* Include a partial/component.
* Supports both module paths and shared paths.
*/
public function include(string $path, array $data = []): void public function include(string $path, array $data = []): void
{ {
$file = $this->resolveViewPath($path); $file = $this->resolvePath($path);
if (file_exists($file)) { if (!file_exists($file)) {
$data['app'] = App::getInstance(); echo "<!-- Include not found: {$path}{$file} -->";
$data['__template'] = $this; return;
extract($data);
require $file;
} }
// Merge parent data with include-specific data
$mergedData = array_merge($this->data, $data);
$__template = $this;
extract($mergedData, EXTR_SKIP);
require $file;
} }
/**
* Include only if the file exists.
*/
public function includeIf(string $path, array $data = []): void public function includeIf(string $path, array $data = []): void
{ {
$file = $this->resolveViewPath($path); $file = $this->resolvePath($path);
if (file_exists($file)) { if (file_exists($file)) {
$this->include($path, $data); $this->include($path, $data);
} }
} }
public function push(string $name): void /**
{ * Output data as JSON for embedding in script tags.
$this->currentStack = $name; */
ob_start(); public function json($data): string
}
public function endPush(): void
{
if ($this->currentStack !== null) {
$content = ob_get_clean();
if (!isset($this->stacks[$this->currentStack])) {
$this->stacks[$this->currentStack] = '';
}
$this->stacks[$this->currentStack] .= $content;
$this->currentStack = null;
}
}
public function stack(string $name): string
{ {
return $this->stacks[$name] ?? ''; return htmlspecialchars(
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT),
ENT_QUOTES,
'UTF-8'
);
} }
/**
* Escape HTML.
*/
public function e(?string $value): string public function e(?string $value): string
{ {
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8'); return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
} }
public function json($data): string /**
* Resolve a dot-notation path to an actual file path.
*
* Resolution order:
* 1. "Layout.main" → app/Shared/Layout/main.php
* 2. "Shared.Components.sidebar" → app/Shared/Components/sidebar.php
* 3. "Members.Views.index" → app/Modules/Members/Views/index.php
* 4. "Members.Views._partials.family-tab" → app/Modules/Members/Views/_partials/family-tab.php
*/
private function resolvePath(string $dotPath): string
{ {
return htmlspecialchars( $parts = explode('.', $dotPath);
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), $relativePath = implode(DIRECTORY_SEPARATOR, $parts) . '.php';
ENT_QUOTES,
'UTF-8' // Check 1: Does it start with "Layout" → Shared/Layout
); if ($parts[0] === 'Layout') {
$file = $this->basePath . '/app/Shared/' . $relativePath;
if (file_exists($file)) {
return $file;
}
}
// Check 2: Does it start with "Shared" → app/Shared/
if ($parts[0] === 'Shared') {
$file = $this->basePath . '/app/' . $relativePath;
if (file_exists($file)) {
return $file;
}
}
// Check 3: Module path → app/Modules/
$file = $this->basePath . '/app/Modules/' . $relativePath;
if (file_exists($file)) {
return $file;
}
// Check 4: Direct path from app/
$file = $this->basePath . '/app/' . $relativePath;
if (file_exists($file)) {
return $file;
}
// Check 5: Try from base path
$file = $this->basePath . '/' . $relativePath;
if (file_exists($file)) {
return $file;
}
// Return the most likely path for error message
return $this->basePath . '/app/Modules/' . $relativePath;
} }
} }
\ No newline at end of file
<?php <?php
$session = App::getInstance()->session(); /**
* Flash alert messages component.
*/
$session = \App\Core\App::getInstance()->session();
$alerts = $session->getAlerts(); $alerts = $session->getAlerts();
if (empty($alerts)) {
return;
}
?> ?>
<?php if (!empty($alerts)): ?> <?php foreach ($alerts as $alert): ?>
<div class="alerts-wrapper"> <?php
<?php foreach ($alerts as $alert): ?> $type = $alert['type'] ?? 'info';
<div class="alert alert-<?= e($alert['type'] ?? 'info') ?>" data-auto-dismiss="5000"> $message = $alert['message'] ?? '';
<span class="alert-message"><?= e($alert['message'] ?? '') ?></span> $bgColor = match($type) {
<button class="alert-close" onclick="this.parentElement.remove()"></button> 'success' => '#F0FDF4',
</div> 'error' => '#FEF2F2',
<?php endforeach; ?> 'warning' => '#FFF7ED',
</div> 'info' => '#EFF6FF',
<?php endif; ?> default => '#F9FAFB',
\ No newline at end of file };
$textColor = match($type) {
'success' => '#059669',
'error' => '#DC2626',
'warning' => '#D97706',
'info' => '#0284C7',
default => '#6B7280',
};
$borderColor = match($type) {
'success' => '#BBF7D0',
'error' => '#FECACA',
'warning' => '#FED7AA',
'info' => '#BFDBFE',
default => '#E5E7EB',
};
$icon = match($type) {
'success' => '✓',
'error' => '✗',
'warning' => '⚠',
'info' => 'ℹ',
default => '',
};
?>
<div class="alert alert-<?= $type ?>" style="background:<?= $bgColor ?>;color:<?= $textColor ?>;border:1px solid <?= $borderColor ?>;padding:12px 20px;border-radius:8px;margin:0 25px 15px;font-size:14px;display:flex;justify-content:space-between;align-items:center;">
<span><?= $icon ?> <?= e($message) ?></span>
<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:18px;color:<?= $textColor ?>;opacity:0.6;padding:0 5px;">&times;</button>
</div>
<?php endforeach; ?>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment