Initial commit

This commit is contained in:
2026-04-06 16:49:17 -04:00
commit 7ec75d0747
36 changed files with 14526 additions and 0 deletions

22
rss/php/autoload.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
spl_autoload_register(function (string $class): void {
$root = rtrim($_SERVER['DOCUMENT_ROOT'], '/');
// PSR-4-ish:
$prefix = 'Vor\\';
$baseDir = $root . '/rss/php/classes/';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require_once $file;
}
}, true, true);

View File

@@ -0,0 +1,110 @@
<?php
namespace Vor\application;
use Vor\core\Sys;
use Vor\core\Main;
class Frontend{
public static function render() {
Sys::start();
$pageName = $_GET['page'] ?? 'index';
$jsonPath = BASE . "/rss/json/pages/$pageName.json";
if (!file_exists($jsonPath)) {
http_response_code(404);
$pageName = '404';
$jsonPath = BASE . "/rss/json/pages/404.json";
}
$conf = json_decode(file_get_contents($jsonPath), true);
if (!self::validate($conf)) {
header('Location: ' . $conf['rules']['redirect_login']);
exit;
}
self::authRedirect($conf);
$viewData = [];
if (isset($conf['init']) && is_array($conf['init'])) {
foreach ($conf['init'] as $task) {
$viewData[$task['return']] = self::execute($task);
}
}
$validatedScripts = [];
$pageScript = "/rss/js/pages/{$conf['layout']['body']}.js";
if(file_exists(BASE . $pageScript)){
$validatedScripts[] = ['src' => $pageScript, 'type' => 'module'];
}
foreach(($conf['scripts'] ?? []) as $s){
if($src = self::getScriptPath($s)){
$validatedScripts[] = ['src' => $src, 'type' => $s['type'] == 'module' ? 'module' : 'text/javascript'];
}
}
return [
'header' => BASE . "/rss/php/views/headers/" . ($conf['layout']['header'] ?? 'default') . ".php",
'view' => BASE . "/rss/php/views/pages/$pageName.php",
'footer' => BASE . "/rss/php/views/footers/" . ($conf['layout']['footer'] ?? 'default') . ".php",
'data' => self::clean($viewData),
'scripts' => $validatedScripts,
'conf' => $conf
];
}
private static function validate($c) {
$restricted = $c['rules']['restricted'] ?? false;
if (!$restricted) return true;
return $_SERVER['VOR_AUTH'];
}
private static function authRedirect($c){
$loginRestricted = $c['rules']['login_restricted'] ?? false;
if($loginRestricted && isset($_SERVER['VOR_AUTH'])){
header('location: ' . $c['rules']['login_redirect'] ?? '/');
exit();
}
}
private static function execute($f) {
$className = "\\Vor\\application\\" . $f['class'];
if (class_exists($className)) {
$instance = new $className();
$method = $f['function'];
if (method_exists($instance, $method)) {
return $instance->$method();
}
}
return null;
}
public static function clean($data){
if(is_array($data)){
return array_map([self::class, 'clean'], $data);
}
return htmlspecialchars(trim((string)$data), ENT_QUOTES, 'UTF-8');
}
public static function loadScripts($conf) {
if (isset($conf['scripts']) && is_array($conf['scripts'])) {
foreach ($conf['scripts'] as $script) {
echo '<script src="/rss/js/' . $script . '.js"></script>' . PHP_EOL;
}
}
}
public static function getScriptPath($script){
$folder = ($script['type'] ?? '') === 'module' ? 'modules' : 'scripts';
$name = $script['name'];
$path = "/rss/js/$folder/$name";
return file_exists(BASE . $path) ? $path : null;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Vor\application\user;
use Vor\core\Sys;
use Vor\core\Main;
use Exception;
class Auth{
public $username;
public $email;
public $password;
public static function isAuth(){
$authData = $_SERVER['VOR_AUTH'] ?? false;
if(!$authData){
return false;
}
return (int)Sys::session('uid') === (int)$authData['uid'];
}
public function login(){
if(isset($this->email) && isset($this->password)){
$userData = Main::select('users', ['email', 'username', 'password', 'id'], ['email' => trim($this->email)]);
if(!$userData){
return false;
}
if(password_verify($this->password, $userData['password'])){
$sid = bin2hex(random_bytes(16));
$payload = [
'uid' => (int)$userData['id'],
'sid' => $sid,
'exp' => time() + 86400
];
if(Sys::cookieSet('v_auth', $payload)){
Sys::validateSession($sid);
Sys::session('uid', (int)$userData['id']);
Sys::session('logged_in_at', time());
return true;
}
}
}
return false;
}
public function logout(){
Sys::cookieClear('v_auth');
if(session_status() === PHP_SESSION_ACTIVE){
session_unset();
session_destroy();
}
return true;
}
}
?>

View File

@@ -0,0 +1,212 @@
<?php
namespace Vor\core;
use PDO;
use Exception;
use Vor\core\Sys;
class Main{
/**
* Static query functions
*/
public static function insert($table, $data, $whitelist = []){
if(!self::assertIdent($table)){
throw new \InvalidArgumentException('Invalid table name');
}
$db = Sys::getConnection();
if(!empty($whitelist)){
$data = array_intersect_key($data, array_flip($whitelist));
}
if(empty($data)){
return false;
}
$fields = array_keys($data);
$cols = implode(', ', $fields);
$placeholders = ':' . implode(', :' , $fields);
$query = "INSERT INTO $table ($cols) VALUES ($placeholders)";
try{
$stmt = $db->prepare($query);
foreach($data as $key => $val){
$stmt->bindValue(':' . $key, $val);
}
if($stmt->execute()){
return $db->lastInsertId();
}
}catch(Exception $e) {
error_log($e->getMessage());
return false;
}
}
public static function update($table, $data, $where, $whitelist = []){
if(!self::assertIdent($table)){
throw new \InvalidArgumentException('Invalid table name');
}
$db = Sys::getConnection();
if(!empty($whitelist)){
$data = array_intersect_key($data, array_flip($whitelist));
}
if(empty($data)){
return false;
}
$setParts = [];
$whereParts = [];
// Set
foreach($data as $key => $val){
$setParts[] = "$key = :$key";
}
$setSql = implode(', ', $setParts);
// Where
foreach($where as $key => $val){
$whereParts[] = "$key = :w_$key";
}
$whereSql = implode(' AND ', $whereParts);
$query = "UPDATE $table SET $setSql WHERE $whereSql";
try{
$stmt = $db->prepare($query);
foreach($data as $key => $val){
$stmt->bindValue(':' . $key, $val);
}
foreach($where as $key => $val){
$stmt->bindValue(':w_' . $key, $val);
}
return $stmt->execute();
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function select($table, $list = [], $where = [], $multiple = false, $assoc = true){
if(!self::assertIdent($table)){
throw new \InvalidArgumentException('Invalid table name');
}
foreach($list as $col){
if(!self::assertIdent($col)){
throw new \InvalidArgumentException('Invalid where name');
}
}
foreach($where as $k => $_){
if(!self::assertIdent($k)){
throw new \InvalidArgumentException('Invalid where key');
}
}
$db = Sys::getConnection();
if(empty($list)){
$query = "SELECT * FROM $table";
}else{
$querySelect = implode(', ', $list);
$query = "SELECT $querySelect FROM $table";
}
if(!empty($where)){
$whereParts = [];
foreach($where as $key => $val){
$whereParts[] = "$key = :w_$key";
}
$whereSql = implode(' AND ', $whereParts);
$query .= " WHERE $whereSql";
}
try{
$stmt = $db->prepare($query);
foreach($where as $key => $val){
$stmt->bindValue(':w_' . $key, $val);
}
if($stmt->execute()){
$mode = $assoc ? PDO::FETCH_ASSOC : PDO::FETCH_COLUMN;
return $multiple ? $stmt->fetchAll($mode) : $stmt->fetch($mode);
}
return false;
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function query($sql, $params = [], $multiple = true, $assoc = true){
$db = Sys::getConnection();
try{
$stmt = $db->prepare($sql);
if(!empty($params)){
foreach($params as $key => $val){
$stmt->bindValue(':' . $key, $val);
}
}
if($stmt->execute()){
$mode = $assoc ? PDO::FETCH_ASSOC : PDO::FETCH_COLUMN;
return $multiple ? $stmt->fetchAll($mode) : $stmt->fetch($mode);
}
return false;
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function curl($url, $data = [], $headers = []){
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
if(!empty($data)){
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
$headers[] = 'Content-Type: application/json';
}
if(!empty($headers)){
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
try{
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
if($error){
error_log('Curl erro: ' . $error);
return false;
}
return [
'status' => $httpCode,
'body' => json_decode($response, true) ?? $response
];
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function assertIdent($s){
return is_string($s) && preg_match('/^[a-zA-Z0-9_]+$/', $s);
}
}

View File

@@ -0,0 +1,539 @@
<?php
namespace Vor\core;
use Exception;
use PDO;
use RangeException;
class Sys
{
// Public
public $userConfigs;
// Protected
protected static $conn = null;
// Methods
/**
* System enviroment handling
*/
public static function start(){
self::loadEnv();
$auth = self::hydrate('v_auth');
if($auth){
$_SERVER['VOR_AUTH'] = $auth;
}
return $auth;
}
public static function getUserConfigs(){
if (file_exists(BASE . '/rss/json/config/groups.json')) {
return json_decode(file_get_contents(BASE . '/rss/json/config/groups.json'), true);
} else {
return [];
}
}
private static function loadEnv(){
$orgPath = $_SERVER['SERVER_ADDR'] == '127.0.0.1' ? '/' : '/../../';
if(file_exists(BASE . $orgPath . '.env')) {
$lines = file(BASE . $orgPath . '.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (isset($lines) && !empty($lines)) {
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) {
continue;
}
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
$value = trim($value, '"\'');
putenv("$name=$value");
}
}
}else{
die('Env was not found at: ' . BASE . $orgPath . '.env');
}
}
/**
* Sys database methods.
* Don't edit this file. If you want to make system changes, extend from this class.
*/
public static function getConnection(){
if (!self::$conn) {
self::createConnection();
}
return self::$conn;
}
private static function createConnection(){
self::loadEnv();
if (self::validateDBEnv()) {
try {
$dbHost = getenv('DB_HOST');
$dbUser = getenv('DB_USER');
$dbPass = getenv('DB_PASS');
$dbName = getenv('DB_NAME');
self::$conn = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4;", $dbUser, $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => true // Remember to validate max connections
]);
self::validateMigration();
} catch (Exception $e) {
error_log($e->getMessage());
return false;
}
} else {
return false;
}
}
private static function validateDBEnv(){
return (getenv('DB_HOST') && getenv('DB_USER') && getenv('DB_PASS') && getenv('DB_NAME'));
}
private static function validateMigration(){
// Validate engine information
try{
self::$conn->exec('CREATE TABLE IF NOT EXISTS engine_info (
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
meta_key VARCHAR(95),
meta_value TEXT
)');
}catch(Exception $e){
// Could not initiate first query
error_log($e->getMessage());
return;
}
// Validate or set engine version
try{
$currentVersion = self::$conn->query('SELECT meta_value FROM engine_info WHERE meta_key = "vor_db_version"')->fetch(PDO::FETCH_COLUMN);
if($currentVersion === false){
self::$conn->exec('INSERT INTO engine_info (meta_key, meta_value) VALUES ("vor_db_version", "0")');
$currentVersion = 0;
}
// Validate latest version
if(file_exists(BASE . '/rss/json/config/migrations.json')){
$migrations = json_decode(file_get_contents(BASE . '/rss/json/config/migrations.json'), true);
$latestVersion = array_key_last($migrations);
if((int)$latestVersion > (int)$currentVersion){
self::migrate();
}
}
}catch(Exception $e){
// First query ran fine but something with the updates messes up
error_log($e->getMessage());
}
}
private static function migrate(){
try{
$currentVersion = (int) self::$conn->query('SELECT meta_value FROM engine_info WHERE meta_key = "vor_db_version"')->fetch(PDO::FETCH_COLUMN) ?? 0;
}catch(Exception $e){
// Could not get version
error_log($e->getMessage());
}
try{
$migrations = json_decode(file_get_contents(BASE . '/rss/json/config/migrations.json'), true);
foreach($migrations as $index => $migration){
if((int)$index > (int)$currentVersion){
foreach($migration as $query){
self::$conn->exec($query);
}
$stmt = self::$conn->prepare("UPDATE engine_info SET meta_value = :index WHERE meta_key = 'vor_db_version'");
$stmt->bindValue(':index', (int)$index);
$stmt->execute();
}
}
}catch(Exception $e){
// Updates failed but DB is fine
error_log($e->getMessage());
}
}
/**
* System utilies
*/
public static function post($key = null){
return $key === null ? $_POST : (isset($_POST[$key]) ? $_POST[$key] : null);
}
public static function get($key = null){
return $key === null ? $_GET : (isset($_GET[$key]) ? $_GET[$key] : null);
}
public static function go($path){
header('Location: ' . $path);
exit();
}
public static function json($data, $code = 200){
header('Content-Type: application/json');
http_response_code($code);
echo json_encode($data);
exit();
}
public static function collect($json = false){
$input = file_get_contents('php://input');
if($json){
$input = json_decode($input, true);
}
return $input ?? [];
}
private static function isHttps(){
return
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') ||
(isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443);
}
public static function session($key, $value = null){
if(session_status() === PHP_SESSION_NONE){
self::validateSession();
}
if($value === null){
return $_SESSION[$key] ?? null;
}
$_SESSION[$key] = $value;
}
public static function hydrate($cookieName = 'v_auth'){
$token = $_COOKIE[$cookieName] ?? false;
if(!$token){
return false;
}
$parts = explode('.', $token, 2);
if(count($parts) !== 2){
return false;
}
$payloadB64 = str_replace(['-', '_'], ['+', '/'], $parts[0]);
$macB64 = str_replace(['-', '_'], ['+', '/'], $parts[1]);
$payload = base64_decode($payloadB64, true);
$mac = base64_decode($macB64, true);
if(!$payload || !$mac){
return false;
}
$key = getenv('TOKEN_SECRET') ?? false;
if(!$key){
return false;
}
$expectedMac = hash_hmac('sha256', $payload, $key, true);
if(!hash_equals($expectedMac, $mac)){
return false;
}
$data = json_decode($payload, true);
if(!is_array($data)){
return false;
}
$uid = isset($data['uid']) ? (int)$data['uid'] : 0;
$exp = isset($data['exp']) ? (int)$data['exp'] : 0;
$sid = isset($data['sid']) ? $data['sid'] : false;
if($uid <= 0 || $exp <= 0 || !$sid){
return false;
}
if($exp < time()){
return false;
}
self::validateSession($sid);
return $data;
}
public static function validateSession($sid = null){
if(session_status() !== PHP_SESSION_NONE){
return true;
}
ini_set('session.use_cookies', 0);
ini_set('session.use_only_cookies', 0);
if($sid){
session_id($sid);
}
return session_start();
}
public static function cookieSet($name, $val, $opts = []){
$isHttps = self::isHttps();
if(is_array($val)){
$key = getenv('TOKEN_SECRET') ?? false;
if(!$key){
return false;
}
$payload = json_encode($val, JSON_UNESCAPED_SLASHES);
$mac = hash_hmac('sha256', $payload, $key, true);
$safePayload = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($payload));
$safeMac = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($mac));
$val = $safePayload . '.' . $safeMac;
}
$defaults = [
'expires' => 0,
'path' => '/',
'domain' => '',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax'
];
$opts = array_intersect_key((array)$opts, array_flip(['expires', 'path', 'domain', 'secure', 'httponly', 'samesite']));
$final = array_merge($defaults, $opts);
if(strcasecmp($final['samesite'], 'None') === 0){
$final['secure'] = true;
}
$success = setcookie($name, (string)$val, $final);
if($success){
$_COOKIE[$name] = (string)$val;
}
return $success;
}
public static function cookieClear($name = 0){
$isHttps = self::isHttps();
$clear = function($cookie) use ($isHttps){
setcookie($cookie, '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax'
]);
unset($_COOKIE[$cookie]);
};
if(!$name){
foreach($_COOKIE as $cookie => $v){
$clear($cookie);
}
return true;
}
$clear($name);
return true;
}
public static function upload($preset, $file){
Sys::loadEnv();
if(!isset($file['error']) || is_array($file['error'])){
return ['ok' => false, 'error' => 'Invalid upload'];
}
if($file['error'] !== UPLOAD_ERR_OK){
return ['ok' => false, 'error' => 'Upload failed'];
}
$dir = getenv("UPLOAD_{$preset}_DIR");
$allowed = getenv("UPLOAD_{$preset}_ALLOWED");
$maxMb = getenv("UPLOAD_{$preset}_MAX_MB");
if(!$dir || !$allowed){
return ['ok' => false, 'error' => 'Upload preset missing'];
}
$allowedExt = array_map('trim', explode(',', strtolower($allowed)));
$maxBytes = (int)$maxMb * 1024 * 1024;
if($maxBytes > 0 && $file['size'] > $maxBytes){
return ['ok' => false, 'error' => 'File too large'];
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if(!in_array($ext, $allowedExt)){
return ['ok' => false, 'error' => 'Invalid file type'];
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if(!$mime){
return ['ok' => false, 'error' => 'Invalid file'];
}
$absDir = rtrim(BASE, '/') . '/' . ltrim($dir, '/');
if(!is_dir($absDir)){
mkdir($absDir, 0755, true);
}
$name = bin2hex(random_bytes(32)) . '.' . $ext;
$dest = rtrim($absDir, '/') . '/' . $name;
if(!move_uploaded_file($file['tmp_name'], $dest)){
return ['ok' => false, 'error' => 'Move failed'];
}
return[
'ok' => true,
'path' => $dir . '/' . $name,
'filename' => $name,
'ext' => $ext,
'mime' => $mime,
'size' => $file['size']
];
}
public static function serveFile($absPath, $opts = []){
$defaults = [
'filename' => null,
'mime' => null,
'inline' => true,
'cacheSeconds' => 86400,
'etag' => true,
'allowRange' => true,
'baseDirs' => [realpath(BASE)],
];
$o = array_merge($defaults, (array)$opts);
$real = realpath($absPath);
if($real === false || !is_file($real) || !is_readable($real)){
http_response_code(404);
exit;
}
$allowed = false;
foreach((array)$o['baseDirs'] as $base){
$baseReal = realpath($base);
if($baseReal && str_starts_with($real, rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)){
$allowed = true;
break;
}
}
if(!$allowed){
http_response_code(404);
exit;
}
$mime = $o['mime'];
if(!$mime){
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($real) ?: 'application/octet-stream';
}
$size = filesize($real);
$mtime = filemtime($real);
if($o['etag']){
$etag = '"' . sha1($real . '|' . $size . '|' . $mtime) . '"';
header('ETag: ' . $etag);
if(isset($_SERVER['HTTP_IF_NONE_MATCH']) &&trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag){
http_response_code(304);
exit;
}
}
if((int)$o['cacheSeconds'] > 0){
header('Cache-Control: public, max-age=' . (int)$o['cacheSeconds']);
}else{
header('Cache-Control: no-store');
}
header('X-Content-Type-Options: nosniff');
header('Content-Type: ' . $mime);
$downloadName = $o['filename'] ?: basename($real);
$downloadName = str_replace(["\r","\n"], '', $downloadName);
$disp = $o['inline'] ? 'inline' : 'attachment';
header('Content-Disposition: ' . $disp . '; filename="' . addslashes($downloadName) . '"');
header('Accept-Ranges: bytes');
$start = 0;
$end = $size - 1;
$status = 200;
if($o['allowRange'] && isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)){
$rangeStart = $m[1] !== '' ? (int)$m[1] : null;
$rangeEnd = $m[2] !== '' ? (int)$m[2] : null;
if($rangeStart === null && $rangeEnd !== null){
$start = max(0, $size - $rangeEnd);
}else{
$start = max(0, (int)$rangeStart);
if($rangeEnd !== null){
$end = min($end, (int)$rangeEnd);
}
}
if($start > $end || $start >= $size){
header('Content-Range: bytes */' . $size);
http_response_code(416);
exit;
}
$status = 206;
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = ($end - $start) + 1;
header('Content-Length: ' . $length);
http_response_code($status);
$fp = fopen($real, 'rb');
if($fp === false){
http_response_code(500);
exit();
}
if($start > 0){
fseek($fp, $start);
}
$chunk = 8192;
while(!feof($fp) && $length > 0){
$read = ($length > $chunk) ? $chunk : $length;
$buf = fread($fp, $read);
if($buf === false){
break;
}
echo $buf;
$length -= strlen($buf);
if(function_exists('fastcgi_finish_request') === false){
flush();
}
}
fclose($fp);
exit;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Vor\core;
class Validator{
public static function int($val) : bool{
return filter_var($val, FILTER_VALIDATE_INT) !== false;
}
public static function float($val) : bool{
return filter_var($val, FILTER_VALIDATE_FLOAT) !== false;
}
public static function string($val, int $minLen = 1): bool{
if(!is_string($val)){
return false;
}
return mb_strlen(trim($val)) >= $minLen;
}
public static function date($date, string $format = 'Y-m-d'): bool {
try{
$d = \DateTime::createFromFormat($format, (string)$date);
return $d && $d->format($format) === (string)$date;
}catch(\Throwable $e){
error_log($e->getMessage());
return false;
}
}
public static function json($val): bool{
if(!is_string($val) || $val === ''){
return false;
}
try{
json_decode($val, true, 512, JSON_THROW_ON_ERROR);
return true;
}catch(\JsonException $e){
error_log($e->getMessage());
return false;
}
}
public static function email($val): bool{
$val = trim((string)$val);
if(preg_match("/[\r\n]/", $val)){
return false;
}
return (bool) filter_var($val, FILTER_VALIDATE_EMAIL);
}
public static function domain($val): bool{
$val = trim((string)$val);
if(preg_match("/[\r\n]/", $val)){
return false;
}
return (bool) filter_var($val, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
}
public static function minLen($val, int $min): bool{
return mb_strlen(trim((string)$val)) >= $min;
}
public static function maxLen($val, int $max): bool{
return mb_strlen(trim((string)$val)) <= $max;
}
public static function regex($val, string $pattern): bool{
return (bool) preg_match($pattern, (string)$val);
}
}

56
rss/php/handler.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
define('BASE', realpath(dirname(__FILE__) . '/../../'));
require_once(BASE . '/rss/php/autoload.php');
use Vor\core\Sys;
Sys::start();
$classMap = [
// Add public allowed classes
'Frontend' => \Vor\application\Frontend::class,
];
$methodMap = [
// Add public allowed methods
'Frontend' => ['clean'],
];
$target = trim((string)($_POST['target'] ?? ''));
$method = trim((string)($_POST['method'] ?? ''));
if($target === '' || $method === ''){
Sys::json(['status' => 'fail', 'message' => 'Missing target'], 400);
}
if(!isset($classMap[$target])){
Sys::json(['status' => 'fail', 'message' => 'Invalid class'], 404);
}
if(!in_array($method, $methodMap[$target] ?? [], true)){
Sys::json(['status' => 'fail', 'message' => 'Invalid method'], 404);
}
$obj = new $classMap[$target];
foreach($_POST as $k => $v){
if($k === 'target' || $k === 'method'){
continue;
}
if(property_exists($obj, $k)){
$obj->$k = $v;
}
}
foreach(($_FILES ?? []) as $k => $v){
if(property_exists($obj, $k)){
$obj->$k = $v;
}
}
try{
$resp = $obj->$method();
Sys::json(['status' => $resp ? 'success' : 'fail', 'message' => $resp], 200);
}catch(\Throwable $e){
error_log($e->getMessage());
Sys::json(['status' => 'fail', 'message' => 'Server error'], 500);
}

148
rss/php/pagehandler.php Normal file
View File

@@ -0,0 +1,148 @@
<?php
require_once($_SERVER['DOCUMENT_ROOT'] . '/rss/php/autoload.php');
use Vor\core\Sys;
use Vor\application\Users;
// Set up class map
$classList = array(
'Sys' => Sys::class,
'Users' => Users::class
);
$page = getPage();
$res = array();
$userConfigs = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/rss/json/config/groups.json'), true);
if(isset($page['init']) && !empty($page['init'])){
foreach($page['init'] as $func){
$res[$func['return']] = initCaller($func);
}
}
// Create init function based on json files
if($page){
if(!isset($_GET['page'])){
$usr = new Users();
$sys = new Sys();
$myInfo = $usr->getMyself();
if($usr->isAuth()){
header('location: /'. $sys::getUserConfigs()[$myInfo['user_type']]['home'].'/');
}
}
}
function getPage(){
$sys = new Sys();
$user = new Users();
global $myInfo;
if(isset($_GET['page']) && !empty($_GET['page'])){
if($conf = pages($_GET['page'])){
if(validatePage($conf)){
return $conf;
}else{
$usr = new Users();
if($usr->isAuth()){
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/'.$sys::getUserConfigs()[$myInfo['user_type']]['home'].'.json')){
header('location: /'.$sys::getUserConfigs()[$myInfo['user_type']]['home'].'/');
}else{
header('location: /');
}
}else{
header('location: /');
}
}
}else{
header('location: /404/');
}
}else{
return pages('index');
}
}
function pages($p){
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/' . $p . '.json')){
return(json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/' . $p . '.json'), true));
}else{
return false;
}
}
function validatePage($c){
if($c['rules']['restricted'] === true){
if($c['rules']['loginCookie'] == true){
$usr = new Users();
if($usr->isAuth()){
$ui = $usr->getMyself();
$allowed_user_types = $c['rules']['userTypes'];
$allowed_user_groups = $c['rules']['userGroups'];
if(in_array($ui['user_type'], $allowed_user_groups) || in_array('*', $allowed_user_groups)){
return true;
}else{
return false;
}
}else{
return false;
}
}else{
return true;
}
}else{
return true;
}
}
function loadModals(){
global $page;
global $res;
if(isset($page['modals']) && !empty($page['modals'])){
foreach($page['modals'] as $modal){
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/php/models/components/modals/' . $modal . '.php')){
include($_SERVER['DOCUMENT_ROOT'] . '/rss/php/models/components/modals/' . $modal . '.php');
}
}
}
}
function getCanonical(){
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
$host = $_SERVER['HTTP_HOST'];
$requestUri = $_SERVER['REQUEST_URI'];
$canonicalUrl = $scheme . "://" . $host . $requestUri;
return $canonicalUrl;
}
function loadScripts(){
if(isset($_GET['page']) && !empty($_GET['page'])){
$page_name = $_GET['page'];
}else{
$page_name = 'index';
}
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/js/' . $page_name . '.js')){
echo '<script src="/rss/js/'.$page_name.'.js"></script>';
}
}
function initCaller($func){
global $classList;
$class = $func['class'];
$method = $func['function'];
$cl = new $classList[$class];
// Set get parameters
$run = true;
if(isset($func['getValues']) && !empty($func['getValues'])){
$run = false;
foreach($func['getValues'] as $get){
if(isset($_GET[$get]) && !empty($_GET[$get])){
$run = true;
$cl->$get = $_GET[$get];
}
}
}
if($run){
$rs = $cl->$method();
return $rs;
}
}

View File

@@ -0,0 +1,6 @@
<script src="/rss/js/main.js"></script>
<?php foreach($render['scripts'] as $s):?>
<script src="<?= $s['src'] ?>" <?= $s['type'] == 'module' ? 'type="module"' : '' ?>></script>
<?php endforeach;?>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VOR CRM UI Kit Showcase (Full)</title>
<link rel="stylesheet" href="/rss/css/main.css" />
<link rel="stylesheet" href="/rss/css/template.css" />
</head>

View File

@@ -0,0 +1,592 @@
<?php include(BASE . '/rss/php/views/components/frame.php');?>
<div class="p-2">
<div class="page">
<section class="card hero-pro mb-4">
<div class="hero-grid">
<div>
<div class="kicker">
<span class="pill">MDI 2026-ish</span>
<span class="small text-muted">tokens · utilities · components</span>
</div>
<h1 class="h3 mt-3 mb-0">Dashboard overview</h1>
<p class="body text-muted mt-2 mb-0">
This page stress-tests: grid, spacing, sizing, typography, tables, buttons, forms,
badges, scrollbar, cards, accordion, switch, drawer, modal, and theme tokens.
</p>
<div class="chip-row mt-3">
<div class="chip is-active" data-chip>Last 7 days</div>
<div class="chip" data-chip>Last 30 days</div>
<div class="chip" data-chip>This year</div>
<div class="chip" data-chip>Custom</div>
</div>
<div class="d-flex align-center gap-2 mt-3">
<span class="badge badge-gray-light">EU region</span>
<span class="badge badge-teal badge-solid">Online</span>
<span class="badge badge-red-light text-red-900">Alerts 2</span>
<span class="small text-muted">Updated Feb 16, 2026</span>
</div>
<div class="mt-3">
<div class="small text-muted">Inline code token</div>
<div class="body">
Try: <code>btn-gray-light btn-sm</code>, <code>col-lg-4</code>, <code>data-toggle</code>, <code>data-theme-toggle</code>.
</div>
</div>
</div>
<div class="card card-quick">
<div class="small text-muted">Quick actions</div>
<div class="d-flex flex-column gap-2 mt-2">
<button class="btn-teal">Create invoice</button>
<button class="btn-blue-light" data-toggle="#modalExample">Edit customer</button>
<button class="btn-gray-light">Log activity</button>
</div>
<hr class="hr">
<div class="small text-muted">System</div>
<div class="d-flex align-center gap-2 mt-2">
<label class="switch">
<input type="checkbox" checked />
<span class="slider"></span>
</label>
<span class="body m-0">Realtime sync</span>
</div>
<div class="mt-3">
<div class="small text-muted">Open modal / drawer</div>
<div class="d-flex gap-2 mt-2">
<button class="btn-gray-light btn-sm" data-toggle="#modalExample">Modal</button>
<button class="btn-gray-light btn-sm" data-toggle="#mobileDrawer">Drawer</button>
</div>
</div>
</div>
</div>
</section>
<div class="row mb-4">
<div class="col-xs-12 col-lg-6">
<div class="alert-teal">alert-teal — Success style container.</div>
</div>
<div class="col-xs-12 col-lg-6">
<div class="alert-red">alert-red — Danger style container.</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-md-4">
<div class="card hoverable mb-3 card-quick">
<div class="stat-title">Total revenue</div>
<div class="stat-value">€ 142,500</div>
<div class="stat-sub"><span class="text-teal-600">+12%</span> vs last month</div>
</div>
</div>
<div class="col-xs-12 col-md-4">
<div class="card hoverable mb-3">
<div class="stat-title">Active users</div>
<div class="stat-value">1,204</div>
<div class="stat-sub">
<span class="badge badge-gray-light">Stable</span>
<span class="small text-muted"> churn 1.1%</span>
</div>
</div>
</div>
<div class="col-xs-12 col-md-4">
<div class="card hoverable mb-3 card-quick">
<div class="stat-title">System status</div>
<div class="d-flex align-center gap-2 mt-2">
<span class="badge badge-teal badge-solid">Healthy</span>
<span class="small text-muted">API 124ms · DB 8ms</span>
</div>
<div class="small text-muted mt-2">Last checked: Feb 16, 2026</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-lg-6">
<div class="card mb-3">
<h4 class="h4 m-0">Forms</h4>
<div class="small text-muted mt-1">input, select, input-group, focus ring</div>
<div class="form-group mt-3">
<label class="label">Email</label>
<input class="input" type="email" placeholder="name@domain.com" />
</div>
<div class="form-group">
<label class="label">Status</label>
<select class="select">
<option>Active</option>
<option>Pending</option>
<option>Paused</option>
</select>
</div>
<div class="form-group">
<label class="label">Search (input-group)</label>
<div class="input-group">
<input class="input" type="text" placeholder="Search..." />
<button class="btn-teal">Go</button>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn-teal">Primary</button>
<button class="btn-gray-light">Secondary</button>
<button class="btn-gray-light btn-sm">Small</button>
<button class="btn-teal btn-lg">Large</button>
</div>
</div>
</div>
<div class="col-xs-12 col-lg-6">
<div class="card mb-3 card-quick">
<h4 class="h4 m-0">Typography & Utilities</h4>
<div class="small text-muted mt-1">sizes, weights, alignment, spacing helpers</div>
<div class="mt-3">
<div class="h1 m-0">h1</div>
<div class="h2 m-0">h2</div>
<div class="h3 m-0">h3</div>
<div class="h4 m-0">h4</div>
<div class="h5 m-0">h5</div>
<div class="h6 m-0">h6</div>
<div class="body mt-2">body text — <span class="text-muted">muted</span> — <span class="text-light">light</span></div>
<div class="small text-muted mt-1">small text</div>
</div>
<hr class="hr">
<div class="d-flex justify-between align-center">
<div class="small text-muted">Badges</div>
<div class="d-flex gap-2">
<span class="badge badge-teal">badge-teal</span>
<span class="badge badge-teal badge-solid">solid</span>
<span class="badge badge-orange-light text-orange-900">warn</span>
<span class="badge badge-red-light text-red-900">danger</span>
</div>
</div>
<div class="mt-3">
<div class="small text-muted mb-2">Spacing / size quick grid</div>
<div class="d-flex gap-2">
<div class="bg-surface border rounded p-2 w-25 text-center small">w-25</div>
<div class="bg-surface border rounded p-2 w-33 text-center small">w-33</div>
<div class="bg-surface border rounded p-2 w-50 text-center small">w-50</div>
</div>
<div class="d-flex gap-2 mt-2">
<div class="bg-surface border rounded p-2 w-66 text-center small">w-66</div>
<div class="bg-surface border rounded p-2 w-33 text-center small">w-33</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-lg-8">
<div class="card table-card mb-3">
<div class="p-3 border-bottom d-flex justify-between align-center">
<h5 class="h5 m-0">Recent transactions</h5>
<div class="d-flex align-center gap-2">
<button class="btn-blue-light btn-sm" id="tableEditAllBtn">Edit mode</button>
<button class="btn-gray-light btn-sm">Export</button>
<button class="btn-gray-light btn-sm">View all</button>
</div>
</div>
<div class="table-wrap">
<div id="transactionsTable"></div>
</div>
<div class="p-3 border-top">
<div class="small text-muted">Small variant</div>
<div class="table-wrap mt-2">
<table class="table table-sm striped hover">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Meta</th>
</tr>
</thead>
<tbody>
<tr>
<td>Theme</td>
<td>data-theme</td>
<td><code>light / dark</code></td>
</tr>
<tr>
<td>Drawer</td>
<td>.drawer</td>
<td><code>is-open</code></td>
</tr>
<tr>
<td>Modal</td>
<td>.modal-overlay</td>
<td><code>is-active</code></td>
</tr>
<tr>
<td>Table edit</td>
<td>.table-edit-btn</td>
<td><code>welcome.js</code></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-lg-4">
<div class="card mb-3">
<h5 class="h5 m-0">Pipeline</h5>
<div class="small text-muted mt-1">progress bars + buttons</div>
<div class="mt-3">
<div class="d-flex justify-between">
<span class="small text-muted">Qualified</span>
<span class="small">18</span>
</div>
<div class="progress mt-1">
<div class="progress-bar progress-72"></div>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-between">
<span class="small text-muted">Proposal</span>
<span class="small">9</span>
</div>
<div class="progress mt-1">
<div class="progress-bar progress-46 soft"></div>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-between">
<span class="small text-muted">Negotiation</span>
<span class="small">4</span>
</div>
<div class="progress mt-1">
<div class="progress-bar progress-25 warn"></div>
</div>
</div>
<div class="d-flex align-center gap-2 mt-4">
<button class="btn-teal btn-sm">Add deal</button>
<button class="btn-gray-light btn-sm">View</button>
<button class="btn-blue-light btn-sm" data-toggle="#modalExample">Edit</button>
<button class="btn-gray-light btn-sm" data-toggle="#modalExample">Open modal</button>
</div>
</div>
<div class="card mb-3">
<h5 class="h5 m-0">Borders / radius / elevation</h5>
<div class="small text-muted mt-1">border helpers + rounded + elevation</div>
<div class="row mt-3 g-0">
<div class="col-xs-6">
<div class="bg-surface border rounded p-3 elevation-1">
<div class="small text-muted">elevation-1</div>
</div>
</div>
<div class="col-xs-6">
<div class="bg-surface border rounded-lg p-3 elevation-4">
<div class="small text-muted">elevation-4</div>
</div>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<div class="bg-surface border rounded-sm p-2 small">rounded-sm</div>
<div class="bg-surface border rounded p-2 small">rounded</div>
<div class="bg-surface border rounded-lg p-2 small">rounded-lg</div>
<div class="bg-surface border rounded-pill p-2 small">pill</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-lg-8">
<div class="card">
<h4 class="h4 m-0">Framework FAQ</h4>
<div class="small text-muted mt-1">accordion + utilities + theme tokens</div>
<div class="accordion-item mt-3">
<button class="accordion-header" data-accordion>
<span>How does the grid work?</span>
<span class="accordion-icon">▼</span>
</button>
<div class="accordion-body">
Negative margins + column padding keeps gutters consistent and safe for nesting.
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" data-accordion>
<span>Does ESC close modals?</span>
<span class="accordion-icon">▼</span>
</button>
<div class="accordion-body">
Yes — with your VOR event delegation, ESC closes modal first, then drawer.
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" data-accordion>
<span>Theme tokens?</span>
<span class="accordion-icon">▼</span>
</button>
<div class="accordion-body">
Toggle <code>data-theme</code> on <code>&lt;html&gt;</code>. CSS vars update automatically.
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-lg-4">
<div class="card">
<h5 class="h5 m-0">Notes</h5>
<div class="small text-muted mt-1">quick sanity checks</div>
<div class="mt-3">
<div class="badge badge-teal">badge-teal</div>
<div class="badge badge-gray-light mt-2">badge-gray-light</div>
<div class="badge badge-orange-light text-orange-900 mt-2">badge-orange-light + text-orange-900</div>
</div>
<div class="mt-4">
<button class="btn-gray-light w-100">btn-gray-light</button>
<button class="btn-gray-light btn-sm w-100 mt-2">btn-gray-light btn-sm</button>
<button class="btn-teal w-100 mt-2">btn-teal</button>
</div>
<div class="mt-4">
<div class="small text-muted mb-2">Visibility / overflow</div>
<div class="bg-surface border rounded p-2 overflow-auto" style="max-height: 90px;">
<div class="small">overflow-auto box</div>
<div class="small text-muted">Line 1</div>
<div class="small text-muted">Line 2</div>
<div class="small text-muted">Line 3</div>
<div class="small text-muted">Line 4</div>
<div class="small text-muted">Line 5</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<div id="modalExample" class="modal-overlay" aria-hidden="true">
<div class="modal-content" role="dialog" aria-modal="true" aria-label="Create or edit entry">
<div class="p-4 border-bottom d-flex justify-between align-center">
<div>
<h5 class="h5 m-0" id="modalTitle">Create new project</h5>
<div class="small text-muted mt-1" id="modalSubTitle">Quick add / edit flow</div>
</div>
<button class="btn-close" data-toggle="#modalExample" aria-label="Close">×</button>
</div>
<div class="p-4">
<div class="alert-blue mb-3">alert-blue — Info message inside modal.</div>
<div class="form-group">
<label class="label">Client</label>
<input type="text" class="input" id="modalClient" placeholder="Enter client..." />
</div>
<div class="form-group">
<label class="label">Status</label>
<select class="select" id="modalStatus">
<option>Paid</option>
<option>Pending</option>
<option>Processing</option>
<option>Overdue</option>
</select>
</div>
<div class="form-group">
<label class="label">Amount</label>
<input type="text" class="input" id="modalAmount" value="" />
</div>
<div class="form-group">
<label class="label">Date</label>
<input type="text" class="input" id="modalDate" placeholder="2026-02-16" />
<div class="small text-muted mt-1">Used by the table edit action and quick-add flow.</div>
</div>
</div>
<div class="p-3 bg-surface border-top text-right">
<button class="btn-gray-light mr-2" data-toggle="#modalExample">Cancel</button>
<button class="btn-teal">Save</button>
</div>
</div>
</div>
<div id="mobileDrawer" class="drawer" aria-hidden="true">
<div class="p-4">
<div class="d-flex justify-between align-center">
<h3 class="h3 m-0">VOR Mobile</h3>
<button class="btn-close" data-toggle="#mobileDrawer" aria-label="Close">×</button>
</div>
<hr class="hr" />
<nav class="d-flex flex-column gap-2">
<a href="#" class="nav-item is-active">Dashboard</a>
<a href="#" class="nav-item">Customers</a>
<a href="#" class="nav-item">Invoices</a>
<a href="#" class="nav-item">Settings</a>
<div class="mt-3">
<div class="small text-muted mb-2">Actions</div>
<button class="btn-teal w-100 mb-2" data-theme-toggle>Toggle theme</button>
<button class="btn-blue-light w-100 mb-2" data-toggle="#modalExample">Edit item</button>
<button class="btn-teal w-100 mb-2" data-toggle="#modalExample">Open modal</button>
<button class="btn-gray-light w-100" data-toggle="#mobileDrawer">Close menu</button>
</div>
</nav>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
VOR.uiTable({
el: "#transactionsTable",
classArr: ["table", "striped", "hover"],
data: [{
client: "Acme Corp",
status: "Paid",
amount: "€ 1,200.00",
date: "Feb 14, 2026"
},
{
client: "Globex Inc",
status: "Pending",
amount: "€ 850.00",
date: "Feb 15, 2026"
},
{
client: "Initech",
status: "Processing",
amount: "€ 2,450.00",
date: "Feb 16, 2026"
},
{
client: "Umbrella",
status: "Overdue",
amount: "€ 640.00",
date: "Feb 10, 2026"
}
],
headers: [{
key: "client",
label: "Client"
},
{
key: "status",
label: "Status"
},
{
key: "amount",
label: "Amount"
},
{
key: "date",
label: "Date"
},
{
key: "edit",
label: "Edit",
sortable: false,
action: "edit-expand",
format: () => "Edit"
}
],
editRenderer: (row) => {
const wrap = VOR.createEl("div", ["p-4", "bg-surface"]);
const title = VOR.createEl("div", ["h6", "m-0", "mb-3"], `Edit ${row.client}`);
const clientGroup = VOR.createEl("div", ["form-group"]);
clientGroup.append(
VOR.createEl("label", ["label"], "Client"),
VOR.createEl("input", ["input"], null, {
type: "text",
value: row.client
})
);
const statusGroup = VOR.createEl("div", ["form-group"]);
const statusSelect = VOR.createEl("select", ["select"]);
["Paid", "Pending", "Processing", "Overdue"].forEach((status) => {
const option = VOR.createEl("option", null, status, {
value: status
});
if (status === row.status) option.selected = true;
statusSelect.append(option);
});
statusGroup.append(
VOR.createEl("label", ["label"], "Status"),
statusSelect
);
const amountGroup = VOR.createEl("div", ["form-group"]);
amountGroup.append(
VOR.createEl("label", ["label"], "Amount"),
VOR.createEl("input", ["input"], null, {
type: "text",
value: row.amount
})
);
const dateGroup = VOR.createEl("div", ["form-group"]);
dateGroup.append(
VOR.createEl("label", ["label"], "Date"),
VOR.createEl("input", ["input"], null, {
type: "text",
value: row.date
})
);
const actions = VOR.createEl("div", ["d-flex", "gap-2", "mt-3"]);
const saveBtn = VOR.createEl("button", ["btn-teal"], "Save", {
type: "button"
});
const closeBtn = VOR.createEl("button", ["btn-gray-light"], "Close", {
type: "button"
});
closeBtn.addEventListener("click", () => {
const editorRow = wrap.closest(".row-editor");
editorRow?.remove();
});
saveBtn.addEventListener("click", () => {
VOR.uiAlert(`Saved ${row.client}`, "success");
});
actions.append(saveBtn, closeBtn);
wrap.append(title, clientGroup, statusGroup, amountGroup, dateGroup, actions);
return wrap;
}
});
});
</script>