First commit.

This commit is contained in:
KJ 2024-09-29 23:16:24 -04:00
commit a6b849e36f
45 changed files with 3375 additions and 0 deletions

16
config.php Normal file
View File

@ -0,0 +1,16 @@
<?php
// Configuración de la base de datos
define('DB_TYPE', 'mysql');
define('DB_HOST', 'localhost');
define('DB_NAME', 'pool');
define('DB_USER', 'root');
define('DB_PASS', '');
// Configuración del sitio
define('SITE_URL', 'https://pool.kj5.top/');
define('PRIVATE_KEY', 'AerohrejaeLohz2eojai2ba1ohCiegoh');
// Configuración avanzada
define('ROOT_DIR', __DIR__);
define('ROOT_CORE', ROOT_DIR.'/src');

24
database.sql Normal file
View File

@ -0,0 +1,24 @@
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255),
password VARCHAR(255)
);
CREATE TABLE pools (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255)
);
CREATE TABLE pool_options (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
value TEXT,
pool_id BIGINT,
FOREIGN KEY (pool_id) REFERENCES pools(id)
);
CREATE TABLE answers (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
option_id BIGINT,
create_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (option_id) REFERENCES pool_options(id)
);

24
public/index.php Normal file
View File

@ -0,0 +1,24 @@
<?php
require_once('../config.php');
// Incluir clases
spl_autoload_register(function ($className) {
$fp = str_replace('\\','/',$className);
$name = basename($fp);
$dir = dirname($fp);
$file = ROOT_CORE.'/'.$dir.'/'.$name.'.php';
if (file_exists($file)) {
require_once $file;
return;
}
});
// Incluir routers
$routers = glob(ROOT_CORE.'/Routers/*.php');
foreach($routers as $file){
require_once($file);
}
\Libs\Router::apply();
?>

4
public/static/css/pico.red.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,44 @@
.centered-container {
display: grid;
min-height: 100vh;
place-content: center;
}
.center {
text-align: center;
}
.pool-options {
display: grid;
gap: 10px;
}
.poll-wrapper {
display: grid;
min-height: 100vh;
align-content: center;
}
.autohide {
animation-name: disapear;
animation-duration: 4000ms;
animation-fill-mode: forwards;
}
@keyframes disapear{
0%{
opacity: 1;
transform: rotateX(90deg);
}
50%{
opacity: 0.5;
transform: rotateX(0deg);
height: auto;
}
100%{
display: none;
opacity: 0;
height: 0px;
transform: rotateX(90deg);
}
}

1
public/static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

46
readme.org Normal file
View File

@ -0,0 +1,46 @@
#+TITLE: Sistema de encuestas simple
#+AUTHOR: KJ
Un sistema de encuestas con solo 3 opciones a elegir (por si acaso, es posible añadir algunas más, pero se harcodeará esa parte).
* Especificaciones
- Encuestas con 3 opciones.
- En otra página ira viendo el resultado.
- Cualquier dispositivo debe ser posible.
- Las opciones se deben poder cambiarse cada 24 horas (iniciar nueva encuesta).
* Diseño de la BD
** Tabla de usuarios
| users |
|----------|
| id |
| email |
| username |
| password |
** Encuestas (En el hardcode solo se trabajará con la última creada)
| pools |
|-------|
| id |
| title |
** Opciones elegibles como respuesta a la encuesta.
| pool_options |
|--------------|
| id |
| value |
| pool_id |
** Votos de los clientes
| anwers |
|-----------|
| id |
| option_id |
| create_at |

0
src/Controllers/.keep Normal file
View File

View File

@ -0,0 +1,19 @@
<?php
namespace Controllers\Pool;
use Libs\HTMX;
use Requests\PoolRequest;
class HomeController {
/**
* handle
*
* @return void
*/
public static function handle(PoolRequest $request): void
{
HTMX::render('Pool', [
'pool' => $request->pool
]);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Controllers\Pool;
use Models\Pool;
use Models\PoolOption;
use Requests\PoolCreateRequest;
class PoolCreateController {
public static function handle(PoolCreateRequest $request): void
{
$pool = new Pool;
$pool->title = $request->post->title;
$pool->beginTransaction();
$pool->save();
for($i=1; $i <= 3; $i++) {
$option = new PoolOption;
$option->value = $request->post->{'option'.$i};
$option->pool_id = $pool->id;
$option->save();
}
$pool->commit();
$request->saved = true;
PoolFormController::handle($request);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Controllers\Pool;
use Libs\HTMX;
use Requests\UserRequest;
class PoolFormController {
public static function handle(UserRequest $request) {
HTMX::render('PoolConfig', [
'saved' => $request->saved ?? false
]);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Controllers\Pool;
use Libs\HTMX;
use Requests\PoolRequest;
class PoolResultController {
public static function handle(PoolRequest $request): void
{
HTMX::render('Result', [
'pool' => $request->pool
]);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Controllers\Pool;
use Models\Answer;
use Requests\VoteRequest;
class PoolVoteController {
public static function handle(VoteRequest $request): void
{
if (!$request->pool->isVoted()) {
$vote = new Answer;
$vote->option_id = $request->option->id;
$vote->save();
$request->pool->createCookie();
}
echo '¡Gracias por participar!';
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Controllers\User;
use Libs\HTMX;
use Models\User;
use Requests\LoginRequest;
class UserLoginController {
public static function handle(LoginRequest $request): void
{
if (!User::login($request->post->username, $request->post->password)) {
$request->onInvalid('Usuario o contraseña equivocados.');
return;
}
HTMX::redirect('/config');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Controllers\User;
use Libs\HTMX;
use Models\User;
class UserLoginFormController {
public static function handle(): void
{
// Crear usuario en caso de que aún no exista.
$user = User::getFirst();
if (is_null($user)) {
$user = new User;
$user->username = 'Paul';
$user->setPassword('F1rbpul');
$user->save();
}
HTMX::render('Login');
}
}

111
src/Libs/Crypto.php Normal file
View File

@ -0,0 +1,111 @@
<?php
/**
* Crypto - DuckBrain
*
* Clase creada para encriptar / desencriptar
* usando openssl con el algoritmo AES 256 CBC.
*
* Depende opcionalmente de la constante PRIVATE_KEY.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Crypto {
/**
* Encripta usando openssl con el algoritmo AES 256 CBC
* y devuelve el resultado en hexadecimal.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function encryptHex(string $data, string $password=PRIVATE_KEY): string
{
return bin2hex(static::encrypt($data, $password));
}
/**
* Desencripta un cadena desde hexadecimal
* y luego usando openssl con el algoritmo AES 256 CBC.
*
* @param string $data Cadena en headecimal
* @param string $password
*
* @return string
*/
public static function decryptHex(string $data, string $password=PRIVATE_KEY): string
{
return static::decrypt(hex2bin($data), $password);
}
/**
* Encripta usando openssl con el algoritmo AES 256 CBC
* y devuelve el resultado en base64.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function encrypt64(string $data, string $password=PRIVATE_KEY): string
{
return base64_encode(static::encrypt($data, $password));
}
/**
* Desencripta un cadena desde base64
* y luego usando openssl con el algoritmo AES 256 CBC.
*
* @param string $data
*
* @return string
*/
public static function decrypt64(string $data, string $password=PRIVATE_KEY): string
{
return static::decrypt(base64_decode($data), $password);
}
/**
* Encripta usando openssl con el algoritmo AES 256 CBC.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function encrypt(string $data, string $password=PRIVATE_KEY): string
{
return openssl_encrypt(
$data,
'AES-256-CBC',
$password,
OPENSSL_RAW_DATA,
substr($password, 0, 16)
);
}
/**
* Desencripta usando openssl con el algoritmo AES 256 CBC.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function decrypt(string $data, string $password=PRIVATE_KEY) : string
{
return openssl_decrypt(
$data,
'AES-256-CBC',
$password,
OPENSSL_RAW_DATA,
substr($password, 0, 16)
);
}
}

58
src/Libs/Database.php Normal file
View File

@ -0,0 +1,58 @@
<?php
/**
* Database - DuckBrain
*
* Clase diseñada para crear y devolver una única instancia PDO (database).
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
use PDO;
use PDOException;
use Exception;
class Database extends PDO {
static private array $databases = [];
private function __construct() {}
/**
* Devuelve una instancia homogénea (singlenton) de la base de datos (PDO).
*
* @return PDO
*/
static public function getInstance(
string $type = 'mysql',
string $host = 'localhost',
string $name = '',
string $user = '',
string $pass = '',
): PDO
{
$key = $type.'/'.$host.'/'.$name.'/'.$user;
if (empty(static::$databases[$key])) {
if ($type == 'sqlite') {
$dsn = $type .':'. $name;
} else
$dsn = $type.':dbname='.$name.';host='.$host;
try {
static::$databases[$key] = new PDO($dsn, $user, $pass);
} catch (PDOException $e) {
throw new Exception(
'Error at connect to database: ' . $e->getMessage()
);
}
static::$databases[$key]->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
static::$databases[$key]->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}
return static::$databases[$key];
}
}
?>

366
src/Libs/HTMLComponent.php Normal file
View File

@ -0,0 +1,366 @@
<?php
/**
* HTMLComponent - DuckBrain
*
* Librería de componentes HTML para Duckbrain.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class HTMLComponent {
/**
* __construct
*
* @param array $properties
* @param string $viewPath
* @param string $extension
*/
public function __construct(
protected array $properties = [],
protected string $content = '',
protected string $viewPath = ROOT_DIR.'/src/Views/',
protected string $extension = '.php',
) {}
/**
* Carga el componente desde un archivo.
*
* @param string $component
*
* @return static
*/
public function load(string $component): static
{
$componentRealName = trim(str_replace(':', '/', $component), ' :');
if (file_exists($this->viewPath.$componentRealName.$this->extension)) {
ob_start();
include($this->viewPath.$componentRealName.$this->extension);
$this->content = ob_get_clean();
$this->parse();
} else {
throw new \Exception(
'"'.$component.'" component not exists.'
);
}
return $this;
}
/**
* Parsea y procesa el contenido en busca de más componentes
*
* @param array $components
*
* @return void
*/
protected function parse(array $components = null): void
{
if (empty($components)) {
preg_match_all(
'/<(([\:]+)?[A-Z][\w1-9:]+) ?([^>]+)?>/s',
$this->content, $matches, PREG_PATTERN_ORDER
);
$components = $matches[1];
}
foreach (array_unique($components) as $component) {
$this->parseComponent($component);
}
}
/**
* Parsea y procesa un componente
*
* @param string $component
*
* @return void
*/
protected function parseComponent(
string $component,
): void
{
$this->content = preg_replace_callback(
'/<'.$component.'([^>]+)?>(.+)?<\/'.$component.'>/sU',
function($matches) use($component) {
$properties = isset($matches[1]) ? static::parseProperties($matches[1]) : [];
$instance = new static($this->properties);
$instance->content = $matches[2] ?? '';
$instance->addProperties($properties);
$instance->load($component);
return $instance->getContent();
},
$this->content
);
}
/**
* Convierte un a cadena en formato "clave='valor' clave2='valor2'" en un array.
*
* @param string $propertiesString
*
* @return array
*/
protected static function parseProperties(string $propertiesString): array
{
preg_match_all('/([\w]+)=[\'"](.+)?[\'"]/sU', $propertiesString, $matches, PREG_PATTERN_ORDER);
$result = [];
foreach($matches[1] as $index => $property) {
$result[$property] = $matches[2][$index];
}
preg_match_all('/([\w]+) /si', $propertiesString.' ', $matches, PREG_PATTERN_ORDER);
foreach($matches[1] as $property) {
$result[$property] = $property;
}
return $result;
}
/**
* Imprime el componente
*
* @return void
*/
public function print(): void
{
print($this->content);
}
/**
* Renderiza un componente
*
* @param string $component
* @param array $properties
*
* @return void
*/
public static function render(string $component, array $properties = []): void
{
$instance = new static($properties);
$instance->load($component);
$instance->print();
}
/**
* Devuelve el componente como string
*
* @return string
*/
public function getContent(): string
{
return $this->content;
}
/**
* Devuelve el valor de una propiedad.
*
* @param string $key
* @param mixed $default
*
* @return mixed
*/
public function property(string $key, mixed $default = null): mixed
{
return $this->properties[$key] ?? $default;
}
/**
* Devuelve el valor de una propiedad escapado con htmlspecialchars.
*
* @param string $key
* @param string $default
*
* @return string
*/
public function escapedProperty(string $key, string $default = ''): string
{
if (isset($this->properties[$key]))
return htmlspecialchars($this->properties[$key] ?? '');
else
return htmlspecialchars($default);
}
/**
* Función alias/apócope de la función property.
*
* @param string $key
* @param mixed $default
*
* @return mixed
*/
public function p(string $key, mixed $default = null): mixed
{
return $this->property($key, $default);
}
/**
* Función alias/apócope de la función escapedProperty.
*
* @param string $key
* @param string $default
*
* @return string
*/
public function e(string $key, string $default = ''): string
{
return $this->escapedProperty($key, $default);
}
/**
* Según el array de nombres de propiedades recibido, los devuelve
* con sus valores como array asociativo.
* Si no recibe ningua propiedad como argumento, devuelve todas las existentes.
*
* @param array $properties
*
* @return array
*/
public function propsToArray(...$properties): array
{
if (empty($properties))
return $this->properties;
$result = [];
foreach ($properties as $property)
$result[$property] = $this->properties[$property] ?? null;
return $result;
}
/**
* Según el array de nombres de propiedades recibido, devuelve
* como atributos HTML en formato propiedad="valor".
*
* @param array $properties
*
* @return string
*/
public function propsToAtts(...$properties): string
{
$result = ' ';
foreach ($properties as $property)
$result .= $property.'="'.htmlspecialchars($this->property($property, '')).'" ';
return $result;
}
/**
* Según el array de nombres de propiedades recibido, devuelve
* como atributos HTML unitarios como checked, required, etc.
*
* @param array $properties
*
* @return string
*/
public function propsToUnary(...$properties): string
{
$result = ' ';
foreach ($properties as $property)
if (isset($this->properties[$property]))
$result .= $property.' ';
return $result;
}
/**
* Trata una propiedad como una definición de propiedad HTML unitaria.
*
* @param string $property Propiedad unitaria buscar.
* @param mixed $result Valor a devolver en caso de estar definda la propiedad. Si es nulo, devuelve $property.
*
* @return mixed
*/
public function unary(string $property, mixed $result = null): mixed
{
if (isset($this->properties[$property])) {
if (isset($result))
return $result;
else
return $property;
}
return null;
}
/**
* Añade una nueva propiedad/atributo.
* En caso de que una propiedad exista, la reemplaza.
*
* @param string $key
* @param mixed $value
*
* @return void
*/
public function addProperty(string $key, mixed $value): void
{
$this->properties = array_merge($this->properties, [$key => $value]);
}
/**
* Añade nuevas propiedades en lote.
* En caso de que una propiedad exista, la reemplaza.
*
* @param array $properties
*
* @return void
*/
public function addProperties(array $properties): void
{
$this->properties = array_merge($this->properties, $properties);
}
/**
* Intenta devolver la url absoluta a partir de una ruta relativa.
*
* @param string $path
*
* @return string
*/
public static function route(string $path = '/'): string
{
if (defined('SITE_URL') && !empty(SITE_URL))
return rtrim(SITE_URL, '/').'/'.ltrim($path, '/');
return $path;
}
/**
* Devuelve la ruta de la petición.
*
* @return string
*/
public static function path(): string
{
return Router::currentPath();
}
/**
* __get
*
* @param string $index
* @return mixed
*/
public function __get(string $index): mixed
{
$value = $this->property($index);
if (is_string($value))
return htmlspecialchars($value);
else
return $value;
}
/**
* __isset
*
* @param string $key
*
* @return bool
*/
public function __isset(string $key): bool
{
return isset($this->properties[$key]);
}
}

291
src/Libs/HTMX.php Normal file
View File

@ -0,0 +1,291 @@
<?php
/**
* HTMX - DuckBrain
*
* Librería de componentes HTMX para duckbrain.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class HTMX extends HTMLComponent {
/**
* Renderiza un componente
*
* @param string $component
* @param array $properties
*
* @return void
*/
public static function render(
string $component,
array $properties = []
): void
{
$instance = new static($properties);
$instance->load($component);
$instance->print();
}
/**
* Parsea y procesa el contenido en busca de más componentes
*
* @param array $components
*
* @return void
*/
protected function parse(array $components = null): void
{
$this->parseHTMX();
parent::parse();
}
/**
* Elimina o muestra secciones según etiquetas de comentarios y
* los estados de la petición HTMX.
*
* Los comentarios posibles son:
*
* | Comentario | Descripción |
* |------------------+---------------------------------------------------|
* | htmx | Solo se muestra si la petición es HTMX |
* | nothtmx | Solo se muestra si la petición no es HTMX |
* | boosted | Solo se muestra si la petición es boosted |
* | notboosted | Solo se muestra si la petición no es boosted |
* | nothtmxorboosted | Se muestra si la petición no es HTMX o es boosted |
* | htmxornotboosted | Se muestra si la petición es HTMX o no es boosted |
*
* @return void
*/
protected function parseHTMX(): void {
if (static::isHtmx())
if (static::isBoosted())
$replacement = ['$3', '', '$3', '', '$3', ''];
else
$replacement = ['$3', '', '', '$3', '', '$3'];
else
$replacement = ['', '$3', '', '$3', '$3', ''];
$this->content = trim(preg_replace(
[
'/([\t \r\n]+)?<!-- htmx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/htmx -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- nothtmx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/nothtmx -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- boosted -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/boosted -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- notboosted -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/notboosted -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- nothtmxorboosted -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/nothtmxorboosted -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- htmxornotboosted -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/htmxornotboosted -->([\t \r\n]+)?/siU',
],
$replacement,
$this->content
));
}
/**
* Elimina las líneas en blanco.
*
* @return void
*/
public function deleteEmptyLines(): void
{
$this->content = trim(preg_replace(
'/^[ \t]*[\r\n]+/m',
'',
$this->content
));
}
/**
* Verifica si la petición es o no htmx.
*
* @return bool
*/
public static function isHtmx(): bool
{
return isset($_SERVER['HTTP_HX_REQUEST']);
}
/**
* Verifica que la petición sea HTMX pero no boosteada.
*
* @return bool
*/
public static function isHtmxNotBoosted(): bool
{
return static::isHtmx() && !static::isBoosted();
}
/**
* Verifica que la petición no sea HTMX o sea Boosteada.
*
* @return bool
*/
public static function notHtmxOrBoosted(): bool
{
return !static::isHtmx() || static::isBoosted();
}
/**
* Redirije a una ruta relativa interna enviando el header adecuado
* si es una petición normal o htmx.
*
* @param string $path
* La ruta relativa a la ruta base.
*
* @return void
*/
public static function redirect(string $path): void
{
if (static::isHtmx())
header('HX-Redirect: '.Router::basePath().ltrim($path, '/'));
else
Router::redirect($path);
exit;
}
/**
* Verifica si la petición es o no Boosted.
*
* @return bool
*/
public static function isBoosted(): bool
{
return isset($_SERVER['HTTP_HX_BOOSTED']) && boolval($_SERVER['HTTP_HX_BOOSTED']);
}
/**
* Devuelve la url actual del navegador cuando se hace la petición HTMX.
*
* @return string
*/
public static function currentURL(): string
{
return $_SERVER['HTTP_HX_CURRENT_URL'] ?? '';
}
/**
* Respuesta del hx-prompt si es que ha sido usado.
*
* @return string
*/
public static function prompt(): string
{
return $_SERVER['HTTP_HX_PROMPT'] ?? '';
}
/**
* Devuelve el id del target HTMX (si existe).
*
* @return string
*/
public static function target(): string
{
return $_SERVER['HTTP_HX_TARGET'] ?? '';
}
/**
* El nombre del elemento que levanta la petición (si existe).
*
* @return string
*/
public static function triggerName(): string
{
return $_SERVER['HTTP_HX_TRIGGER_NAME'] ?? '';
}
/**
* El id del elemento que levanta la petición (si existe).
*
* @return string
*/
public static function triggerId(): string
{
return $_SERVER['HTTP_HX_TRIGGER'] ?? '';
}
/**
* Solo cuando es una petición HTMX devuelve la propiedad HTMX hx-swap-oob.
*
* @param string $value Valor de hx-swap (por defecto: true).
* @param bool $excludeBoosted Excluir si la petición es boosted (por defecto: false)
*
* @return string
*/
public static function swapOob(string $value = 'true', bool $excludeBoosted = false): string
{
if ($excludeBoosted && static::isBoosted())
return '';
return static::isHtmx() ? 'hx-swap-oob="'.$value.'"' : '';
}
/**
* Solo cuando es una petición HTMX devuelve la propiedad HTMX hx-select-oob.
*
* @param string $selector
*
* @return string
*/
public static function selectOob(string $selector): string
{
return static::isHtmx() ? 'hx-select-oob="'.$selector.'"' : '';
}
/**
* Fuerza a cambiar la URL del navegador y la coloca en el historial
* mientras que HTMX crea un caché de la página para cuando se de clic
* en regresar.
*
* Equivamente a usar la propiedad htmx hx-push-url.
*
* @param string $url Ruta relativa.
*
* @return void
*/
public static function pushUrl(string $url): void
{
if (static::isHtmx())
header('HX-Push-Url: '.static::route($url));
}
/**
* Fuerza a cambiar la URL del navegador y pero sin colocarla en el historial.
*
* Equivamente a usar la propiedad htmx hx-push-url.
*
* @param string $url Ruta relativa.
*
* @return void
*/
public static function replaceUrl(string $url): void
{
if (static::isHtmx())
header('HX-Replace-Url: '.static::route($url));
}
/**
* Cambia el swap target de HTMX.
*
* @param string $selector
*
* @return void
*/
public static function retarget(string $selector): void
{
header('HX-Retarget: '.$selector);
}
/**
* Cambia el swap rule de HTMX.
*
* @param string $rule
*
* @return void
*/
public static function reswap(string $rule = 'innerHTML'): void
{
header('HX-Reswap: '.$rule);
}
}

28
src/Libs/Middleware.php Normal file
View File

@ -0,0 +1,28 @@
<?php
/**
* Middleware - DuckBrain
*
* Librería base para middlewares.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Middleware {
/**
* Llama al siguiente callback.
*
* @param Neuron $req
*
* @return mixed
*/
public static function next(Neuron $req): mixed
{
$next = array_pop($req->next);
return call_user_func_array($next, [$req]);
}
}

938
src/Libs/Model.php Normal file
View File

@ -0,0 +1,938 @@
<?php
/**
* Model - DuckBrain
*
* Modelo ORM para objetos que hagan uso de una base de datos.
* Depende de Libs\Database y hace uso de las constantes
* DB_TYPE, DB_HOST, DB_NAME, DB_USER y DB_PASS.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
use Libs\Database;
use PDO;
use PDOException;
use Exception;
use ReflectionClass;
use ReflectionProperty;
use AllowDynamicProperties;
#[AllowDynamicProperties]
class Model {
public ?int $id = null;
protected array $toNull = [];
static protected string $primaryKey = 'id';
static protected array $ignoreSave = ['id'];
static protected array $forceSave = [];
static protected string $table;
static protected string $tableSufix = 's';
static protected array $queryVars = [];
static protected array $querySelect = [
'select' => ['*'],
'where' => '',
'from' => '',
'leftJoin' => '',
'rightJoin' => '',
'innerJoin' => '',
'orderBy' => '',
'groupBy' => '',
'limit' => ''
];
/**
* Sirve para obtener la instancia de la base de datos.
*
* @return PDO
*/
protected static function db(): PDO
{
if (DB_TYPE == 'sqlite')
return Database::getInstance(
type: DB_TYPE,
name: DB_NAME
);
else
return Database::getInstance(
DB_TYPE,
DB_HOST,
DB_NAME,
DB_USER,
DB_PASS
);
}
/**
* Ejecuta PDO::beginTransaction para iniciar una transacción.
* Más info: https://www.php.net/manual/es/pdo.begintransaction.php
*
* @return bool
*/
public function beginTransaction(): bool
{
return static::db()->beginTransaction();
}
/**
* Ejecuta PDO::rollBack para deshacher los cambios de una transacción.
* Más info: https://www.php.net/manual/es/pdo.rollback.php
*
* @return bool
*/
public function rollBack(): bool
{
if ( static::db()->inTransaction())
return static::db()->rollBack();
else
return true;
}
/**
* Ejecuta PDO::commit para consignar una transacción.
* Más info: https://www.php.net/manual/es/pdo.commit.php
*
* @return bool
*/
public function commit(): bool
{
if (static::db()->inTransaction())
return static::db()->commit();
else
return true;
}
/**
* Ejecuta una sentencia SQL en la base de datos.
*
* @param string $query
* Contiene la sentencia SQL que se desea ejecutar.
*
* @throws Exception
* En caso de que la sentencia SQL falle, devolverá un error en
* pantalla y hará rolllback en caso de estar dentro de una
* transacción (ver método beginTransacction).
*
* @param bool $resetQuery
* Indica si el query debe reiniciarse o no (por defecto es true).
*
* @return array
* Contiene el resultado de la llamada SQL .
*/
protected static function query(string $query, bool $resetQuery = true): array
{
$db = static::db();
try {
$prepared = $db->prepare($query);
$prepared->execute(static::$queryVars);
} catch (PDOException $e) {
if ($db->inTransaction())
$db->rollBack();
$vars = json_encode(static::$queryVars);
echo "<pre>";
throw new Exception(
"\nError at query to database.\n" .
"Query: $query\n" .
"Vars: $vars\n" .
"Error:\n" . $e->getMessage()
);
}
$result = $prepared->fetchAll();
if ($resetQuery)
static::resetQuery();
return $result;
}
/**
* Reinicia la configuración de la sentencia SQL.
* @return void
*/
protected static function resetQuery(): void
{
static::$querySelect = [
'select' => ['*'],
'where' => '',
'from' => '',
'leftJoin' => '',
'rightJoin' => '',
'innerJoin' => '',
'orderBy' => '',
'groupBy' => '',
'limit' => ''
];
static::$queryVars = [];
}
/**
* Construye la sentencia SQL a partir static::$querySelect y una vez
* construída, llama a resetQuery.
*
* @return string
* Contiene la sentencia SQL.
*/
protected static function buildQuery(): string
{
$sql = 'SELECT '.join(', ', static::$querySelect['select']);
if (static::$querySelect['from'] != '')
$sql .= ' FROM '.static::$querySelect['from'];
else
$sql .= ' FROM '.static::table();
if(static::$querySelect['innerJoin'] != '')
$sql .= static::$querySelect['innerJoin'];
if (static::$querySelect['leftJoin'] != '')
$sql .= static::$querySelect['leftJoin'];
if(static::$querySelect['rightJoin'] != '')
$sql .= static::$querySelect['rightJoin'];
if (static::$querySelect['where'] != '')
$sql .= ' WHERE '.static::$querySelect['where'];
if (static::$querySelect['groupBy'] != '')
$sql .= ' GROUP BY '.static::$querySelect['groupBy'];
if (static::$querySelect['orderBy'] != '')
$sql .= ' ORDER BY '.static::$querySelect['orderBy'];
if (static::$querySelect['limit'] != '')
$sql .= ' LIMIT '.static::$querySelect['limit'];
return $sql;
}
/**
* Configura $queryVars para vincular un valor a un
* parámetro de sustitución y devuelve este último.
*
* @param string $value
* Valor a vincular.
*
* @return string
* Parámetro de sustitución.
*/
private static function bindValue(string $value): string
{
$index = ':v_'.count(static::$queryVars);
static::$queryVars[$index] = $value;
return $index;
}
/**
* Crea una instancia del objeto actual a partir de un arreglo.
*
* @param mixed $elem
* Puede recibir un arreglo o un objeto que contiene los valores
* que tendrán sus atributos.
*
* @return static
* Retorna un objeto de la clase actual.
*/
protected static function getInstance(array $elem = []): static
{
$class = get_called_class();
$instance = new $class;
$reflection = new ReflectionClass($instance);
$properties = $reflection->getProperties();
$propertyNames = array_column($properties, 'name');
foreach ($elem as $key => $value) {
$index = array_search($key, $propertyNames);
if (is_numeric($index) && isset($value) &&
enum_exists($properties[$index]->getType()->getName()))
$instance->$key = $properties[$index]->getType()->getName()::tryfrom($value);
else
$instance->$key = $value;
}
return $instance;
}
/**
* Devuelve los atributos a guardar de la case actual.
* Los atributos serán aquellos que seran public y
* no esten excluidos en static::$ignoresave y aquellos
* que sean private o protected pero estén en static::$forceSave.
*
* @return array
* Contiene los atributos indexados del objeto actual.
*/
protected function getVars(): array
{
$reflection = new ReflectionClass($this);
$properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);
$result = [];
foreach($properties as $property)
$result[$property->name] = isset($this->{$property->name})
? $this->{$property->name} : null;
foreach (static::$ignoreSave as $del)
unset($result[$del]);
foreach (static::$forceSave as $value)
$result[$value] = isset($this->$value)
? $this->$value: null;
foreach ($result as $i => $property) {
if (gettype($property) == 'boolean')
$result[$i] = $property ? '1' : '0';
if ($property instanceof \UnitEnum)
$result[$i] = $property->value;
}
return $result;
}
/**
* Devuelve el nombre de la clase actual aunque sea una clase extendida.
*
* @return string
* Devuelve el nombre de la clase actual.
*/
public static function className(): string
{
return strtolower(
preg_replace(
'/(?<!^)[A-Z]/', '_$0',
substr(
strrchr(get_called_class(), '\\'), 1
)
)
);
}
/**
* Construye (a partir del nombre de la clase y el sufijo en static::$tableSufix)
* y/o develve el nombre de la tabla de la BD en la que se alojará o
* se aloja el objeto actual.
*
* @return string
*/
protected static function table(): string
{
if (isset(static::$table))
return static::$table;
return static::className().static::$tableSufix;
}
/**
* Actualiza los valores en la BD con los valores del objeto actual.
* @return void
*/
protected function update(): void
{
$atts = $this->getVars();
foreach ($atts as $key => $value) {
if (isset($value)) {
if (in_array($key, $this->toNull))
$set[]="$key=NULL";
else {
$set[]="$key=:$key";
static::$queryVars[':'.$key] = $value;
}
} else {
if (in_array($key, $this->toNull))
$set[]="$key=NULL";
}
}
$table = static::table();
$pk = static::$primaryKey;
$pkv = $this->$pk;
$sql = "UPDATE $table SET ".join(', ', $set)." WHERE $pk='$pkv'";
static::query($sql);
}
/**
* Inserta una nueva fila en la base de datos a partir del
* objeto actual.
* @return void
*/
protected function add(): void
{
$db = static::db();
$atts = $this->getVars();
foreach ($atts as $key => $value) {
if (isset($value)) {
$into[] = "`$key`";
$values[] = ":$key";
static::$queryVars[":$key"] = $value;
}
}
$table = static::table();
$sql = "INSERT INTO $table (".join(', ', $into).") VALUES (".join(', ', $values).")";
static::query($sql);
$pk = static::$primaryKey;
$this->$pk = $db->lastInsertId();
}
/**
* Revisa si el objeto a guardar es nuevo o no y según el resultado
* llama a update para actualizar o add para insertar una nueva fila.
* @return void
*/
public function save(): void
{
$pk = static::$primaryKey;
if (isset($this->$pk))
$this->update();
else
$this->add();
}
/**
* Elimina el objeto actual de la base de datos.
* @return void
*/
public function delete(): void {
$table = static::table();
$pk = static::$primaryKey;
$sql = "DELETE FROM $table WHERE $pk=:$pk";
static::$queryVars[":$pk"] = $this->$pk;
static::query($sql);
}
/**
* Define SELECT en la sentencia SQL.
*
* @param array $columns
* Columnas que se selecionarán en la consulta SQL.
*
* @return static
*/
public static function select(array $columns): static
{
static::$querySelect['select'] = $columns;
return new static();
}
/**
* Define FROM en la sentencia SQL.
*
* @param array $tables
* Tablas que se selecionarán en la consulta SQL.
*
* @return static
*/
public static function from(array $tables): static
{
static::$querySelect['from'] = join(', ', $tables);
return new static();
}
/**
* Define el WHERE en la sentencia SQL.
*
* @param string $column
* La columna a comparar.
*
* @param string $operatorOrValue
* El operador o el valor a comparar como igual en caso de que $value no se defina.
*
* @param string $value
* (opcional) El valor a comparar en la columna.
*
* @param bool $no_filter
* (opcional) Se usa cuando $value es una columna o un valor que no requiere filtros
* contra ataques SQLI (por defeco es false).
*
* @return static
*/
public static function where(
string $column,
string $operatorOrValue,
string $value = null,
bool $no_filter = false
): static
{
return static::and(
$column,
$operatorOrValue,
$value,
$no_filter
);
}
/**
* Define AND en la sentencia SQL (se puede anidar).
*
* @param string $column
* La columna a comparar.
*
* @param string $operatorOrValue
* El operador o el valor a comparar como igual en caso de que $value no se defina.
*
* @param string $value
* (opcional) El valor el valor a comparar en la columna.
*
* @param bool $no_filter
* (opcional) Se usa cuando $value es una columna o un valor que no requiere filtros
* contra ataques SQLI (por defecto es false).
*
* @return static
*/
public static function and(
string $column,
string $operatorOrValue,
string $value = null,
bool $no_filter = false
): static
{
if (is_null($value)) {
$value = $operatorOrValue;
$operatorOrValue = '=';
}
if (!$no_filter)
$value = static::bindValue($value);
if (static::$querySelect['where'] == '')
static::$querySelect['where'] = "$column $operatorOrValue $value";
else
static::$querySelect['where'] .= " AND $column $operatorOrValue $value";
return new static();
}
/**
* Define OR en la sentencia SQL (se puede anidar).
*
* @param string $column
* La columna a comparar.
*
* @param string $operatorOrValue
* El operador o el valor a comparar como igual en caso de que $value no se defina.
*
* @param string $value
* (opcional) El valor el valor a comparar en la columna.
*
* @param bool $no_filter
* (opcional) Se usa cuando $value es una columna o un valor que no requiere filtros
* contra ataques SQLI (por defecto es false).
*
* @return static
*/
public static function or(
string $column,
string $operatorOrValue,
string $value = null,
bool $no_filter = false
): static
{
if (is_null($value)) {
$value = $operatorOrValue;
$operatorOrValue = '=';
}
if (!$no_filter)
$value = static::bindValue($value);
if (static::$querySelect['where'] == '')
static::$querySelect['where'] = "$column $operatorOrValue $value";
else
static::$querySelect['where'] .= " OR $column $operatorOrValue $value";
return new static();
}
/**
* Define WHERE usando IN en la sentencia SQL.
*
* @param string $column
* La columna a comparar.
*
* @param array $arr
* Arreglo con todos los valores a comparar con la columna.
*
* @param bool $in
* Define si se tienen que comprobar negativa o positivamente.
*
* @return static
*/
public static function where_in(
string $column,
array $arr,
bool $in = true
): static
{
$arrIn = [];
foreach($arr as $value) {
$arrIn[] = static::bindValue($value);
}
if ($in)
$where_in = "$column IN (".join(', ', $arrIn).")";
else
$where_in = "$column NOT IN (".join(', ', $arrIn).")";
if (static::$querySelect['where'] == '')
static::$querySelect['where'] = $where_in;
else
static::$querySelect['where'] .= " AND $where_in";
return new static();
}
/**
* Define LEFT JOIN en la sentencia SQL.
*
* @param string $table
* Tabla que se va a juntar a la del objeto actual.
*
* @param string $columnA
* Columna a comparar para hacer el join.
*
* @param string $operatorOrColumnB
* Operador o columna a comparar como igual para hacer el join en caso de que $columnB no se defina.
*
* @param string $columnB
* (opcional) Columna a comparar para hacer el join.
*
* @return static
*/
public static function leftJoin(
string $table,
string $columnA,
string $operatorOrColumnB,
string $columnB = null
): static
{
if (is_null($columnB)) {
$columnB = $operatorOrColumnB;
$operatorOrColumnB = '=';
}
static::$querySelect['leftJoin'] .= ' LEFT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static();
}
/**
* Define RIGHT JOIN en la sentencia SQL.
*
* @param string $table
* Tabla que se va a juntar a la del objeto actual.
*
* @param string $columnA
* Columna a comparar para hacer el join.
*
* @param string $operatorOrColumnB
* Operador o columna a comparar como igual para hacer el join en caso de que $columnB no se defina.
*
* @param string $columnB
* (opcional) Columna a comparar para hacer el join.
*
* @return static
*/
public static function rightJoin(
string $table,
string $columnA,
string $operatorOrColumnB,
string $columnB = null
): static
{
if (is_null($columnB)) {
$columnB = $operatorOrColumnB;
$operatorOrColumnB = '=';
}
static::$querySelect['rightJoin'] .= ' RIGHT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static();
}
/**
* Define INNER JOIN en la sentencia SQL.
*
* @param string $table
* Tabla que se va a juntar a la del objeto actual.
*
* @param string $columnA
* Columna a comparar para hacer el join.
*
* @param string $operatorOrColumnB
* Operador o columna a comparar como igual para hacer el join en caso de que $columnB no se defina.
*
* @param string $columnB
* (opcional) Columna a comparar para hacer el join.
*
* @return static
*/
public static function innerJoin(
string $table,
string $columnA,
string $operatorOrColumnB,
string $columnB = null
): static
{
if (is_null($columnB)) {
$columnB = $operatorOrColumnB;
$operatorOrColumnB = '=';
}
static::$querySelect['innerJoin'] .= ' INNER JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static();
}
/**
* Define GROUP BY en la sentencia SQL.
*
* @param array $arr
* Columnas por las que se agrupará.
*
* @return static
*/
public static function groupBy(array $arr): static
{
static::$querySelect['groupBy'] = join(', ', $arr);
return new static();
}
/**
* Define LIMIT en la sentencia SQL.
*
* @param int $offsetOrQuantity
* Define el las filas a ignorar o la cantidad a tomar en
* caso de que $quantity no esté definido.
* @param int $quantity
* Define la cantidad máxima de filas a tomar.
*
* @return static
*/
public static function limit(int $offsetOrQuantity, ?int $quantity = null): static
{
if (is_null($quantity))
static::$querySelect['limit'] = $offsetOrQuantity;
else
static::$querySelect['limit'] = $offsetOrQuantity.', '.$quantity;
return new static();
}
/**
* Define ORDER BY en la sentencia SQL.
*
* @param string $value
* Columna por la que se ordenará.
*
* @param string $order
* (opcional) Define si el orden será de manera ascendente (ASC),
* descendente (DESC) o aleatorio (RAND).
*
* @return static
*/
public static function orderBy(string $value, string $order = 'ASC'): static
{
if ($value == "RAND") {
static::$querySelect['orderBy'] = 'RAND()';
return new static();
}
if (!(strtoupper($order) == 'ASC' || strtoupper($order) == 'DESC'))
$order = 'ASC';
static::$querySelect['orderBy'] = $value.' '.$order;
return new static();
}
/**
* Retorna la cantidad de filas que hay en un query.
*
* @param bool $resetQuery
* (opcional) Indica si el query debe reiniciarse o no (por defecto es true).
*
* @param bool $useLimit
* (opcional) Permite usar limit para estabecer un máximo inical y final para contar.
* Requiere que se haya definido antes el límite (por defecto en false).
*
* @return int
*/
public static function count(bool $resetQuery = true, bool $useLimit = false): int
{
if (!$resetQuery)
$backup = [
'select' => static::$querySelect['select'],
'limit' => static::$querySelect['limit'],
'orderBy' => static::$querySelect['orderBy']
];
if ($useLimit && static::$querySelect['limit'] != '') {
static::$querySelect['select'] = ['1'];
static::$querySelect['orderBy'] = '';
$sql = 'SELECT COUNT(1) AS quantity FROM ('.static::buildQuery().') AS counted';
$queryResult = static::query($sql, $resetQuery);
$result = $queryResult[0]['quantity'];
} else {
static::$querySelect['select'] = ["COUNT(".static::table().".".static::$primaryKey.") as quantity"];
static::$querySelect['limit'] = '1';
static::$querySelect['orderBy'] = '';
$sql = static::buildQuery();
$queryResult = static::query($sql, $resetQuery);
$result = $queryResult[0]['quantity'];
}
if (!$resetQuery) {
static::$querySelect['select'] = $backup['select'];
static::$querySelect['limit'] = $backup['limit'];
static::$querySelect['orderBy'] = $backup['orderBy'];
}
return $result;
}
/**
* Obtiene una instancia según su primary key (generalmente id).
* Si no encuentra una instancia, devuelve nulo.
*
* @param mixed $id
*
* @return static|null
*/
public static function getById(mixed $id): ?static
{
return static::where(static::$primaryKey, $id)->getFirst();
}
/**
* Realiza una búsqueda en la tabla de la instancia actual.
*
* @param string $search
* Contenido a buscar.
*
* @param array $in
* (opcional) Columnas en las que se va a buscar (null para buscar en todas).
*
* @return static
*/
public static function search(string $search, array $in = null): static
{
if ($in == null) {
$className = get_called_class();
$in = array_keys((new $className())->getVars());
}
$search = static::bindValue($search);
$where = [];
if (DB_TYPE == 'sqlite')
foreach($in as $row)
$where[] = "$row LIKE '%' || $search || '%'";
else
foreach($in as $row)
$where[] = "$row LIKE CONCAT('%', $search, '%')";
if (static::$querySelect['where']=='')
static::$querySelect['where'] = join(' OR ', $where);
else
static::$querySelect['where'] = static::$querySelect['where'] .' AND ('.join(' OR ', $where).')';
return new static();
}
/**
* Obtener los resultados de la consulta SQL.
*
* @param bool $resetQuery
* (opcional) Indica si el query debe reiniciarse o no (por defecto es true).
*
* @return array<static>
* Arreglo con instancias del la clase actual resultantes del query.
*/
public static function get(bool $resetQuery = true): array
{
$sql = static::buildQuery();
$result = static::query($sql, $resetQuery);
$instances = [];
foreach ($result as $row) {
$instances[] = static::getInstance($row);
}
return $instances;
}
/**
* El primer elemento de la consulta SQL.
*
* @param bool $resetQuery
* (opcional) Indica si el query debe reiniciarse o no (por defecto es true).
*
* @return static|null
* Puede retornar una instancia de la clase actual o null.
*/
public static function getFirst(bool $resetQuery = true): ?static
{
static::limit(1);
$instances = static::get($resetQuery);
return empty($instances) ? null : $instances[0];
}
/**
* Obtener todos los elementos del la tabla de la instancia actual.
*
* @return array<static>
* Contiene un arreglo de instancias de la clase actual.
*/
public static function all(): array
{
$sql = 'SELECT * FROM '.static::table();
$result = static::query($sql);
$instances = [];
foreach ($result as $row)
$instances[] = static::getInstance($row);
return $instances;
}
/**
* Permite definir como nulo el valor de un atributo.
* Sólo funciona para actualizar un elemento de la BD, no para insertar.
*
* @param string|array $atts
* Atributo o arreglo de atributos que se definirán como nulos.
*
* @return void
*/
public function setNull(string|array $atts): void
{
if (is_array($atts)) {
foreach ($atts as $att)
if (!in_array($att, $this->toNull))
$this->toNull[] = $att;
return;
}
if (!in_array($atts, $this->toNull))
$this->toNull[] = $atts;
}
}
?>

54
src/Libs/Neuron.php Normal file
View File

@ -0,0 +1,54 @@
<?php
/**
* Neuron - DuckBrain
*
* Neuron, sirve para crear un objeto que alojará valores, pero
* además tiene la característica especial de que al intentar
* acceder a un atributo que no está definido devolerá nulo en
* lugar de generar un error php notice que indica que se está
* intentando acceder a un valor no definido.
*
* El constructor recibe un objeto o arreglo con los valores que
* estarán definidos.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
use AllowDynamicProperties;
#[AllowDynamicProperties]
class Neuron {
/**
* __construct
*
* @param array $data
*/
public function __construct(...$data)
{
if (count($data) === 1 &&
isset($data[0]) &&
(is_array($data[0]) ||
is_object($data[0])))
$data = $data[0];
foreach($data as $key => $value)
$this->{$key} = $value;
}
/**
* __get
*
* @param string $index
* @return null
*/
public function __get(string $index): null
{
return null;
}
}
?>

144
src/Libs/Request.php Normal file
View File

@ -0,0 +1,144 @@
<?php
/**
* Request - DuckBrain
*
* Libería complementaria de la libería Router.
* Contiene el cuerpo básico de la petición http (POST, GET, JSON, etc).
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Request extends Neuron {
public Neuron $get;
public Neuron $post;
public Neuron $put;
public Neuron $patch;
public Neuron $json;
public Neuron $params;
public string $path;
public string $error;
public array $next;
/**
* __construct
*
* @param string $path Ruta actual tomando como raíz la instalación de DuckBrain.
*/
public function __construct()
{
$this->path = Router::currentPath();
$this->get = new Neuron($_GET);
$this->post = new Neuron($_POST);
$this->put = new Neuron();
$this->patch = new Neuron();
$contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';
if ($contentType === "application/json")
$this->json = new Neuron(
(object) json_decode(trim(file_get_contents("php://input")), false)
);
else {
$this->json = new Neuron();
if (in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'PATCH'])) {
parse_str(file_get_contents("php://input"), $input_vars);
$this->{strtolower($_SERVER['REQUEST_METHOD'])} = new Neuron($input_vars);
}
}
$this->params = new Neuron();
}
/**
* Corre las validaciones e intenta continuar con la pila de callbacks.
*
* @return mixed
*/
public function handle(): mixed
{
if ($this->validate())
return Middleware::next($this);
return null;
}
/**
* Inicia la validación que se haya configurado.
*
* @return bool
*/
public function validate(): bool
{
$actual = match($_SERVER['REQUEST_METHOD']) {
'GET', 'DELETE' => $this->get,
default => $this->{strtolower($_SERVER['REQUEST_METHOD'])}
};
if (Validator::validateList(static::paramRules(), $this->params) &&
Validator::validateList(static::getRules(), $this->get ) &&
Validator::validateList(static::rules(), $actual))
return true;
if (isset(static::messages()[Validator::$lastFailed]))
$error = static::messages()[Validator::$lastFailed];
else {
$error = 'Error: validation failed of '.preg_replace('/\./', ' as ', Validator::$lastFailed, 1);
}
return static::onInvalid($error);
}
/**
* Reglas para el método actual.
*
* @return array
*/
public static function rules(): array {
return [];
}
/**
* Reglas para los parámetros por URL.
*
* @return array
*/
public static function paramRules(): array {
return [];
}
/**
* Reglas para los parámetros GET.
*
* @return array
*/
public static function getRules(): array {
return [];
}
/**
* Mensajes de error en caso de fallar una validación.
*
* @return array
*/
public static function messages(): array {
return [];
}
/**
* Función a ejecutar cuando se ha detectado un valor no válido.
*
* @param string $error
*
* @return false
*/
protected function onInvalid(string $error): false
{
http_response_code(422);
print($error);
return false;
}
}

374
src/Libs/Router.php Normal file
View File

@ -0,0 +1,374 @@
<?php
/**
* Router - DuckBrain
*
* Librería de Enrrutador.
* Depende de manera forzada de que la constante ROOT_DIR esté definida
* y de manera optativa de que la constante SITE_URL lo esté también.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Router {
private static $get = [];
private static $post = [];
private static $put = [];
private static $patch = [];
private static $delete = [];
private static $last;
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
/**
* Función callback por defectio para cuando
* no se encuentra configurada la ruta.
*
* @return void
*/
public static function defaultNotFound (): void
{
header("HTTP/1.0 404 Not Found");
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Página no encontrada</h2>';
}
/**
* __construct
*/
private function __construct() {}
/**
* Parsea para deectar las pseudovariables (ej: {variable})
*
* @param string $path
* Ruta con pseudovariables.
*
* @param callable $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return array
* Arreglo con 2 índices:
* path - Contiene la ruta con las pseudovariables reeplazadas por expresiones regulares.
* callback - Contiene el callback en formato Namespace\Clase::Método.
*/
private static function parse(string $path, callable $callback): array
{
preg_match_all('/{(\w+)}/s', $path, $matches, PREG_PATTERN_ORDER);
$paramNames = $matches[1];
$path = preg_quote($path, '/');
$path = preg_replace(
['/\\\{\w+\\\}/s'],
['([^\/]+)'],
$path);
return [
'path' => $path,
'callback' => [$callback],
'paramNames' => $paramNames
];
}
/**
* Devuelve el ruta base o raiz del proyecto sobre la que trabajará el router.
*
* Ej: Si la url del sistema está en "https://ejemplo.com/duckbrain"
* entonces la ruta base sería "/duckbrain"
*
* @return string
*/
public static function basePath(): string
{
if (defined('SITE_URL') && !empty(SITE_URL))
return rtrim(parse_url(SITE_URL, PHP_URL_PATH), '/').'/';
return str_replace($_SERVER['DOCUMENT_ROOT'], '/', ROOT_DIR);
}
/**
* Redirije a una ruta relativa interna.
*
* @param string $path
* La ruta relativa a la ruta base.
*
* Ej: Si nuesto sistema está en "https://ejemplo.com/duckbrain"
* llamamos a Router::redirect('/docs'), entonces seremos
* redirigidos a "https://ejemplo.com/duckbrain/docs".
* @return void
*/
public static function redirect(string $path): void
{
header('Location: '.static::basePath().ltrim($path, '/'));
exit;
}
/**
* Añade un middleware a la última ruta usada.
* Solo se puede usar un middleware a la vez.
*
* @param callable $callback
* @param int $prioriry
*
* @return static
* Devuelve la instancia actual.
*/
public static function middleware(callable $callback, int $priority = null): static
{
if (!isset(static::$last))
return new static();
$method = static::$last[0];
$index = static::$last[1];
if (isset($priority) && $priority <= 0)
$priority = 1;
if (is_null($priority) || $priority >= count(static::$$method[$index]['callback']))
static::$$method[$index]['callback'][] = $callback;
else {
static::$$method[$index]['callback'] = array_merge(
array_slice(static::$$method[$index]['callback'], 0, $priority),
[$callback],
array_slice(static::$$method[$index]['callback'], $priority)
);
}
return new static();
}
/**
* Reconfigura el callback final de la última ruta.
*
* @param callable $callback
*
* @return static
*/
public static function reconfigure(callable $callback): static
{
if (empty(static::$last))
return new static();
$method = static::$last[0];
$index = static::$last[1];
static::$$method[$index]['callback'][0] = $callback;
return new static();
}
/**
* Configura calquier método para todas las rutas.
*
* En caso de no recibir un callback, busca la ruta actual
* solo configura la ruta como la última configurada
* siempre y cuando la misma haya sido configurada previamente.
*
* @param string $method
* Método http.
* @param string $path
* Ruta con pseudovariables.
* @param callable|null $callback
*
* @return
* Devuelve la instancia actual.
*/
public static function configure(string $method, string $path, ?callable $callback = null): static
{
if (is_null($callback)) {
$path = preg_quote($path, '/');
$path = preg_replace(
['/\\\{\w+\\\}/s'],
['([^\/]+)'],
$path);
foreach(static::$$method as $index => $router)
if ($router['path'] == $path) {
static::$last = [$method, $index];
break;
}
return new static();
}
static::$$method[] = static::parse($path, $callback);
static::$last = [$method, count(static::$$method)-1];
return new static();
}
/**
* Define los routers para el método GET.
*
* @param string $path
* Ruta con pseudovariables.
* @param callable $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return static
* Devuelve la instancia actual.
*/
public static function get(string $path, callable $callback = null): static
{
return static::configure('get', $path, $callback);
}
/**
* Define los routers para el método POST.
*
* @param string $path
* Ruta con pseudovariables.
* @param callable $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return static
* Devuelve la instancia actual.
*/
public static function post(string $path, callable $callback = null): static
{
return static::configure('post', $path, $callback);
}
/**
* Define los routers para el método PUT.
*
* @param string $path
* Ruta con pseudovariables.
* @param callable $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return static
* Devuelve la instancia actual
*/
public static function put(string $path, callable $callback = null): static
{
return static::configure('put', $path, $callback);
}
/**
* Define los routers para el método PATCH.
*
* @param string $path
* Ruta con pseudovariables.
* @param callable $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return static
* Devuelve la instancia actual
*/
public static function patch(string $path, callable $callback = null): static
{
return static::configure('patch', $path, $callback);
}
/**
* Define los routers para el método DELETE.
*
* @param string $path
* Ruta con pseudovariables
* @param callable $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return static
* Devuelve la instancia actual
*/
public static function delete(string $path, callable $callback = null): static
{
return static::configure('delete', $path, $callback);
}
/**
* Devuelve la ruta actual tomando como raíz la ruta de instalación de DuckBrain.
*
* @return string
*/
public static function currentPath() : string
{
return preg_replace('/'.preg_quote(static::basePath(), '/').'/',
'/', strtok($_SERVER['REQUEST_URI'], '?'), 1);
}
/**
* Aplica la configuración de rutas.
*
* @param string $path (opcional) Ruta a usar. Si no se define, detecta la ruta actual.
*
* @return void
*/
public static function apply(string $path = null): void
{
$path = $path ?? static::currentPath();
$routers = match($_SERVER['REQUEST_METHOD']) { // Según el método selecciona un arreglo de routers
'POST' => static::$post,
'PUT' => static::$put,
'PATCH' => static::$patch,
'DELETE' => static::$delete,
default => static::$get
};
foreach ($routers as $router) { // revisa todos los routers para ver si coinciden con la ruta actual
if (preg_match_all('/^'.$router['path'].'\/?$/si',$path, $matches, PREG_PATTERN_ORDER)) {
unset($matches[0]);
// Objtener un reflection del callback
$lastCallback = $router['callback'][0];
if ($lastCallback instanceof \Closure) { // si es función anónima
$reflectionCallback = new \ReflectionFunction($lastCallback);
} else {
if (is_string($lastCallback))
$lastCallback = preg_split('/::/', $lastCallback);
// Revisamos su es un método o solo una función
if (count($lastCallback) == 2)
$reflectionCallback = new \ReflectionMethod($lastCallback[0], $lastCallback[1]);
else
$reflectionCallback = new \ReflectionFunction($lastCallback[0]);
}
// Obtener los parámetros
$arguments = $reflectionCallback->getParameters();
if (isset($arguments[0])) {
// Obtenemos la clase del primer parámetro
$argumentClass = strval($arguments[0]->getType());
// Verificamos si la clase está o no tipada
if (empty($argumentClass)) {
$request = new Request;
} else {
$request = new $argumentClass;
// Verificamos que sea instancia de Request (requerimiento)
if (!($request instanceof Request))
throw new \Exception('Bad argument type on router callback.');
}
} else {
$request = new Request;
}
// Comprobando y guardando los parámetros variables de la ruta
if (isset($matches[1])) {
foreach ($matches as $index => $match) {
$paramName = $router['paramNames'][$index-1];
$request->params->$paramName = urldecode($match[0]);
}
}
// Llama a la validación y luego procesa la cola de callbacks
$request->next = $router['callback'];
$data = $request->handle();
// Por defecto imprime como JSON si se retorna algo
if (isset($data)) {
header('Content-Type: application/json');
print(json_encode($data));
}
return;
}
}
// Si no hay router que coincida llamamos a $notFoundCallBack
call_user_func_array(static::$notFoundCallback, [new Request]);
}
}

201
src/Libs/Validator.php Normal file
View File

@ -0,0 +1,201 @@
<?php
/**
* Validator - DuckBrain
*
* Libería complementaria de la libería Request.
* Sirve para simplpificar la verificación de valores.
*
* Tiene la posibilida de verificar tanto reglas individuales como en lote.
*
* |----------+--------------------------------------------------------|
* | Regla | Descripción |
* |----------+--------------------------------------------------------|
* | not | Niega la siguiente regla. Ej: not:float |
* | exists | Es requerido; debe estar definido y puede estar vacío |
* | required | Es requerido; debe estar definido y no vacío |
* | number | Es numérico |
* | int | Es entero |
* | float | Es un float |
* | bool | Es booleano |
* | email | Es un correo |
* | enum | Esta en un lista ve valores. Ej: enum:admin,user,guest |
* | url | Es una url válida |
* |----------+--------------------------------------------------------|
*
* Las listas de reglas están separadas por |, Ej: required|email
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Validator {
public static string $lastFailed = '';
/**
* Validar lista de reglas sobre las propiedades de un objeto.
*
* @param array $rulesList Lista de reglas.
* @param Neuron $haystack Objeto al que se le verificarán las reglas.
*
* @return bool Retorna true solo si todas las reglas se cumplen y false en cuanto una falle.
*/
public static function validateList(array $rulesList, Neuron $haystack): bool
{
foreach ($rulesList as $target => $rules) {
$rules = preg_split('/\|/', $rules);
foreach ($rules as $rule) {
if (static::checkRule($haystack->{$target}, $rule))
continue;
static::$lastFailed = $target.'.'.$rule;
return false;
}
}
return true;
}
/**
* Revisa si una regla se cumple.
*
* @param mixed $subject Lo que se va a verfificar.
* @param string $rule La regla a probar.
*
* @return bool
*/
public static function checkRule(mixed $subject, string $rule): bool
{
$arguments = preg_split('/[:,]/', $rule);
$rule = [static::class, $arguments[0]];
$arguments[0] = $subject;
if (is_callable($rule))
return call_user_func_array($rule, $arguments);
throw new \Exception('Bad rule: "'.preg_split('/::/', $rule)[1].'"' );
}
/**
* Verifica la regla de manera negativa.
*
* @param mixed $subject Lo que se va a verfificar.
* @param mixed $rule La regla a probar.
*
* @return bool
*/
public static function not(mixed $subject, ...$rule): bool
{
return !static::checkRule($subject, join(':', $rule));
}
/**
* Comprueba que que esté definido/exista.
*
* @param mixed $subject
*
* @return bool
*/
public static function exists(mixed $subject): bool
{
return isset($subject);
}
/**
* Comprueba que que esté definido y no esté vacío.
*
* @param mixed $subject
*
* @return bool
*/
public static function required(mixed $subject): bool
{
return isset($subject) && !empty($subject);
}
/**
* number
*
* @param mixed $subject
*
* @return bool
*/
public static function number(mixed $subject): bool
{
return is_numeric($subject);
}
/**
* int
*
* @param mixed $subject
*
* @return bool
*/
public static function int(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_INT);
}
/**
* float
*
* @param mixed $subject
*
* @return bool
*/
public static function float(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_FLOAT);
}
/**
* bool
*
* @param mixed $subject
*
* @return bool
*/
public static function bool(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_BOOLEAN);
}
/**
* email
*
* @param mixed $subject
*
* @return bool
*/
public static function email(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_EMAIL);
}
/**
* url
*
* @param mixed $subject
*
* @return bool
*/
public static function url(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_URL);
}
/**
* enum
*
* @param mixed $subject
* @param mixed $values
*
* @return bool
*/
public static function enum(mixed $subject, ...$values): bool
{
return in_array($subject, $values);
}
}

0
src/Middlewares/.keep Normal file
View File

0
src/Models/.keep Normal file
View File

15
src/Models/Answer.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace Models;
use Libs\Model;
class Answer extends Model {
public int $option_id;
public string $create_at;
public function add(): void
{
$this->create_at = date('Y-m-d H:i:s');
parent::add();
}
}

65
src/Models/Pool.php Normal file
View File

@ -0,0 +1,65 @@
<?php
namespace Models;
use Libs\Crypto;
use Libs\Model;
class Pool extends Model {
public string $title;
protected array $options;
protected int $votes;
public function votes(): int
{
if (!isset($this->votes))
$this->votes = Answer::where_in('option_id', array_column($this->getOptions(), 'id'))->count();
return $this->votes;
}
/**
* Devuelve las opciones de la encuesta.
*
* @return array<PoolOption>
*/
public function getOptions(): array
{
if (!isset($this->options))
$this->options = PoolOption::where('pool_id', $this->id)->get();
return $this->options;
}
/**
* Crea la cookie del voto para evitar doble votación.
*
* @return void
*/
public function createCookie(): void
{
$cookieValue = Crypto::encrypt64(json_encode([
'pid' => $this->id,
'exp' => time()+43200
]));
setcookie('POOL_VOTE', $cookieValue, time()+43200, parse_url(SITE_URL, PHP_URL_PATH));
}
/**
* Verifica si ya se ha emitido su voto en la encuesta actual.
*
* @return bool
*/
public function isVoted(): bool
{
if (!isset($_COOKIE['POOL_VOTE']))
return false;
$cookie = json_decode(Crypto::decrypt64($_COOKIE['POOL_VOTE']));
return (json_last_error() == JSON_ERROR_NONE &&
$cookie->exp > time() &&
$cookie->pid == $this->id);
}
}

18
src/Models/PoolOption.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace Models;
use Libs\Model;
class PoolOption extends Model {
public string $value;
public int $pool_id;
protected int $votes;
public function votes(): int
{
if (!isset($this->votes))
$this->votes = Answer::where('option_id', $this->id)->count();
return $this->votes;
}
}

95
src/Models/User.php Normal file
View File

@ -0,0 +1,95 @@
<?php
namespace Models;
use Libs\Crypto;
use Libs\Model;
class User extends Model {
public string $username;
protected string $password;
static protected array $forceSave = ['password'];
/**
* Definir la contraseña.
*
* @param string $password
* @return void
*/
public function setPassword(string $password): void
{
$this->password = password_hash($password, PASSWORD_DEFAULT);
}
/**
* Iniciar sesión.
*
* @param string $username
* @param string $password
*
* @return bool
*/
public static function login(string $username, string $password): bool
{
$user = User::where('username', $username)->getFirst();
if(isset($user) && password_verify($password, $user->password)) {
$user->createLoginCookie();
return true;
}
return false;
}
/**
* Crea la cookie de sesión.
* @return void
*/
public function createLoginCookie(): void
{
setcookie('POOL_LC', $this->getSessionToken(), time()+1209600, parse_url(SITE_URL, PHP_URL_PATH));
}
/**
* Cerrar sessión.
* @return void
*/
public static function logout(): void {
setcookie('POOL_LC', '', 0);
}
/**
* Verifica si el hay un usuario logueado.
*
* @return static|null
*/
public static function isLogged(): static|null
{
if (!isset($_COOKIE['POOL_LC']))
return null;
$cookie = json_decode(Crypto::decrypt64($_COOKIE['POOL_LC']));
if (json_last_error() == JSON_ERROR_NONE &&
isset($cookie->expire) &&
$cookie->expire > time())
return User::where('username', $cookie->username)->getFirst();
return null;
}
/**
* Devuelve una llave encriptada asociada al correo del usuario.
*
* @return string
*/
public function getSessionToken(int $expiration = 1209600): string
{
return Crypto::encrypt64(
json_encode([
'username' => $this->username,
'expire' => time() + $expiration
])
);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Requests;
use Libs\HTMX;
use Libs\Request;
class LoginRequest extends Request {
public static function rules(): array
{
return [
'username' => 'required',
'password' => 'required',
];
}
public static function messages(): array
{
return [
'username.required' => 'No se ha definido un usuario.',
'password.required' => 'No se ha definido una contraseña.',
];
}
public function onInvalid(string $error): false
{
HTMX::retarget('.alert');
echo $error;
return false;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Requests;
use Libs\HTMX;
class PoolCreateRequest extends UserRequest {
public static function rules(): array
{
return [
'title' => 'required',
'option1' => 'required',
'option2' => 'required',
'option3' => 'required',
];
}
public static function messages(): array
{
return [
'title.required' => 'No se ha definido un título.',
'option1.required' => 'No se ha definido la opción 01.',
'option2.required' => 'No se ha definido la opción 02.',
'option3.required' => 'No se ha definido la opción 03.',
];
}
public function onInvalid(string $error): false
{
HTMX::retarget('.alert');
echo $error;
return false;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Requests;
use Libs\Request;
use Models\Pool;
class PoolRequest extends Request {
public ?Pool $pool;
public function validate(): bool
{
$this->pool = Pool::orderBy('id', 'DESC')->getFirst();
if (is_null($this->pool))
return $this->onInvalid('Aún no hay una encuesta configurada.');
return parent::validate();
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Requests;
use Libs\HTMX;
use Libs\Request;
use Models\User;
class UserRequest extends Request {
public ?User $user;
public function validate(): bool
{
$this->user = User::isLogged();
if (is_null($this->user)) {
HTMX::redirect('/login');
return false;
}
return parent::validate();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Requests;
use Models\PoolOption;
class VoteRequest extends PoolRequest {
public ?PoolOption $option;
public function validate(): bool
{
if (!parent::validate())
return false;
if ($this->pool->id != $this->params->pid)
return $this->onInvalid('Voto no válido.');
$this->option = PoolOption::getById($this->params->vid);
if (is_null($this->option) || $this->option->pool_id != $this->params->pid)
return $this->onInvalid('Voto no válido.');
return true;
}
public static function paramRules(): array
{
return [
'pid' => 'int',
'vid' => 'int'
];
}
}

0
src/Routers/.keep Normal file
View File

16
src/Routers/pool.php Normal file
View File

@ -0,0 +1,16 @@
<?php
use Controllers\Pool\HomeController;
use Controllers\Pool\PoolCreateController;
use Controllers\Pool\PoolFormController;
use Controllers\Pool\PoolResultController;
use Controllers\Pool\PoolVoteController;
use Libs\Router;
Router::get('/', [HomeController::class, 'handle']);
Router::get('/config', [PoolFormController::class, 'handle']);
Router::post('/config', [PoolCreateController::class, 'handle']);
Router::post('/pool/{pid}/vote/{vid}', [PoolVoteController::class, 'handle']);
Router::get('/results', [PoolResultController::class, 'handle']);

8
src/Routers/user.php Normal file
View File

@ -0,0 +1,8 @@
<?php
use Controllers\User\UserLoginController;
use Controllers\User\UserLoginFormController;
use Libs\Router;
Router::get('/login', [UserLoginFormController::class, 'handle']);
Router::post('/login', [UserLoginController::class, 'handle']);

0
src/Views/.keep Normal file
View File

16
src/Views/Layout.php Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="<?=$this->route('static/css/pico.red.min.css')?>" rel="stylesheet"/>
<link href="<?=$this->route('static/css/style.css')?>" rel="stylesheet"/>
<title><?=$this->title?></title>
</head>
<body>
<main class="container">
<?=$this->content?>
</main>
<script src="<?=$this->route('static/js/htmx.min.js')?>"></script>
</body>
</html>

38
src/Views/Login.php Normal file
View File

@ -0,0 +1,38 @@
<Layout title="Ingresar">
<div class="centered-container">
<article>
<header>
<h1 class="center">Iniciar sesión</h1>
</header>
<form method="POST" hx-boost="true">
<hr/>
<fieldset>
<input
type="text"
name="username"
placeholder="Usuario"
autocomplete="off"
required
/>
<input
type="password"
name="password"
placeholder="Contraseña"
autocomplete="off"
required
/>
</fieldset>
<button type="submit">
Enviar
</button>
<div
hx-on::after-swap="setTimeout(function(){document.querySelector('.alert').innerHTML='' }, 4000)"
class="alert center"></div>
</form>
</article>
</div>
</Layout>

23
src/Views/Pool.php Normal file
View File

@ -0,0 +1,23 @@
<Layout title="Encuesta">
<div class="poll-wrapper">
<article class="center">
<header>
<h1><?=htmlspecialchars($this->pool->title)?></h1>
</header>
<div class="pool-options">
<?php if ($this->pool->isVoted()): ?>
¡Gracias por participar!
<?php else: ?>
<?php foreach($this->pool->getOptions() as $option): ?>
<div role="button"
hx-target=".pool-options"
hx-post="<?=$this->route('/pool/'.$this->pool->id.'/vote/'.$option->id)?>"
tabindex="0">
<?=htmlspecialchars($option->value)?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</article>
</div>
</Layout>

55
src/Views/PoolConfig.php Normal file
View File

@ -0,0 +1,55 @@
<Layout title="Configuración">
<div class="centered-container">
<article>
<header>
<h1 class="center">Configurar nueva encuesta</h1>
</header>
<form method="POST" hx-boost="true">
<hr/>
<fieldset>
<input
type="text"
name="title"
placeholder="Título de la encuesta"
autocomplete="off"
required
/>
<input
type="text"
name="option1"
placeholder="Opción 01"
autocomplete="off"
required
/>
<input
type="text"
name="option2"
placeholder="Opción 02"
autocomplete="off"
required
/>
<input
type="text"
name="option3"
placeholder="Opción 03"
autocomplete="off"
required
/>
</fieldset>
<button type="submit">
Crear encuesta
</button>
<div
hx-on::after-swap="setTimeout(function(){document.querySelector('.alert').innerHTML='' }, 4000)"
class="alert center"></div>
<?php if ($this->saved): ?>
<div class="center autohide">
Se ha creado la encuesta!!
</div>
<?php endif; ?>
</form>
</article>
</div>
</Layout>

37
src/Views/Result.php Normal file
View File

@ -0,0 +1,37 @@
<!-- nothtmx -->
<Layout title="Encuesta">
<div class="poll-wrapper"
hx-trigger="every 1s"
hx-get="<?=$this->route('/results')?>">
<!-- /nothtmx -->
<article class="center">
<header>
<h1><?=htmlspecialchars($this->pool->title)?></h1>
</header>
<div class="pool-options">
<?php foreach($this->pool->getOptions() as $option): ?>
<?php
if ($option->votes() == 0)
$percent = 0;
else
$percent = round(($option->votes() * 100 / $this->pool->votes()), 2);
?>
<label>
<strong>
<?=htmlspecialchars($option->value)?>:
</strong>
<?=$percent?>% (<?=$option->votes()?> votos)
<progress value="<?=$percent?>" max="100"/>
</label>
<?php endforeach; ?>
</div>
<footer>
<strong>
Total de votos: <?=$this->pool->votes()?>
</strong>
</footer>
</article>
<!-- nothtmx -->
</div>
</Layout>
<!-- /nothtmx -->