chore: scaffold SeedProject base (Phase 1)

Clean-room copy of the reusable engines from comiida, with all
instance data, secrets, dependencies, and build output excluded:
- app/         Astro theme skeleton (no comiida blog posts; hero image -> placeholder)
- api/         SeedProject PHP framework (no vendor/.env/config.php)
- content-pipeline/  engine only (scripts/admin/prompts; empty runtime state)
- astroagent.config.json + app/.astroagent/skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
This commit is contained in:
Carlos Arias 2026-07-04 22:53:10 +00:00
commit 1559ce017d
146 changed files with 37974 additions and 0 deletions

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# ============================================================================
# SeedProject base — cloneable Astro + PHP foundation
# Tracked: engine code (app/src, api framework, content-pipeline, astroagent).
# Ignored: secrets, dependencies, build output, and per-site runtime state.
# ============================================================================
# --- secrets (NEVER commit) ---
**/.env
api/config.php
api/system/.installed
# --- dependencies (installed per site via scripts/new-site.sh) ---
node_modules/
api/vendor/
# --- build output (regenerable) ---
app/dist/
app/.astro/
public/
public-preview/
# --- logs ---
*.log
# --- per-site runtime state (regenerated; dirs kept via .gitkeep) ---
content-pipeline/logs/*
!content-pipeline/logs/.gitkeep
content-pipeline/drafts/*
!content-pipeline/drafts/.gitkeep
content-pipeline/state/*
!content-pipeline/state/.gitkeep
content-pipeline/calendar.json
content-pipeline/messages.json
content-pipeline/news-queue.json
content-pipeline/news-scan.json
content-pipeline/research-scan.json
# --- astroagent runtime workspaces ---
.astroagent/jobs/
.astroagent/work/
app/.astroagent/jobs/
app/.astroagent/work/

12
api/.htaccess Normal file
View file

@ -0,0 +1,12 @@
RewriteEngine On
#RewriteCond %{HTTPS} !=on
#RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301,NE]
Header always set Content-Security-Policy "upgrade-insecure-requests;"
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^(.+)$ index.php?url=$1 [QSA,L]
# enable PHP error logging

16
api/.memory/changelog.md Normal file
View file

@ -0,0 +1,16 @@
# SeedProject Changelog
## 2026-04-27 — Cleanup and permissions
- **`.claude/settings.local.json`** — added `COMPOSER_ALLOW_SUPERUSER=1 composer install` to allowed commands; reordered hooks block
- **`SeedProjectNaked.zip`** — removed stale zip artifact from repo
## 2026-04-27 — Fix HTTP 500 on boot
### PHP 8.3 Compatibility
- **`core/Database.php`** — renamed `query()` to `execQuery()` to resolve fatal signature mismatch with `PDO::query()` in PHP 8.3; updated internal calls in `retrieve()` and `pagination()`
- **`app/Components/Role.php`** — updated `deletePerm()` to call `execQuery()` instead of `query()`
### Composer / Autoloader
- **`composer.json`** — created from scratch (was missing); added `autoload.classmap` for `core/`, `app/Helpers`, `app/Controllers`, `app/Components`, `app/Gateways`, `system/` so framework classes load via composer
- **`vendor/`** — ran `composer dump-autoload` to regenerate autoload files with proper classmap

2392
api/.memory/documentation.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,850 @@
# Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the SeedProject `api/` into an installable, DB-backed, two-tier-secured backend that a static Astro site calls same-origin, proven by a live health round-trip.
**Architecture:** A CLI installer provisions config + schema; a migrations runner evolves it; two namespaced base controllers (`PublicController`, `ApiController`) enforce a public (origin + rate-limit) and privileged (bearer / `api_auth` key) tier over a shared JSON envelope; a `/api/health` endpoint queries MariaDB and returns JSON, called from an Astro page.
**Tech Stack:** PHP 8.3, Symfony Console 5.4, MariaDB 10.11 (PDO/`Db` facade), Apache + php-fpm, Astro (static). No PHPUnit in this repo — verification is done with `php console`, `curl`, and `mysql` commands.
**Spec:** `api/.memory/foundation.md`. **Conventions:** [documentation.md](documentation.md), [commands.md](../commands/commands.md).
**Autoload note:** New classes use PSR-4 — `App\Services\*``app/Services/*.php`, `App\Controllers\*``app/Controllers/*.php` (composer.json maps `App\``app`). No `composer dump-autoload` needed for these. URL controllers stay global in `public/controllers/` (Bootstrap `require`s them) and `use` the namespaced bases. Core files (`core/*`) are never modified.
---
## File structure
**Create**
- `app/Services/Installer.php``App\Services\Installer`: test DB, import `dump.sql`, generate keys, write `config.php`, write install lock.
- `app/Services/Migrator.php``App\Services\Migrator`: ensure `migrations` table, list/apply `db/migrations/*.sql`.
- `commands/InstallCommand.php``php console app:install`.
- `commands/MigrateCommand.php``php console db:migrate [--status]`.
- `app/Controllers/JsonController.php``App\Controllers\JsonController extends \Controller`: `json()` envelope + `throttle()`.
- `app/Controllers/PublicController.php``App\Controllers\PublicController extends JsonController`: `checkOrigin()`.
- `app/Controllers/ApiController.php``App\Controllers\ApiController extends JsonController`: `authenticate()` (bearer/`api_auth`).
- `public/controllers/health.php` — global `Health extends App\Controllers\PublicController`.
- `public/controllers/admin.php` — global `Admin extends App\Controllers\ApiController`.
- `db/migrations/001_create_metrics_placeholder.sql` — a trivial first migration to prove the runner.
- `app/src/pages/api-health-test.astro` — client-side round-trip proof page.
**Modify**
- `console` — register `InstallCommand`, `MigrateCommand`.
- `install/controllers/index.php` — delegate to `Installer`; refuse if locked.
- Apache vhost `/www/server/panel/vhost/apache/comiida.com.conf` — deny `install/`, `db/`, `.installed`.
**Server (outside repo)**
- MariaDB `comiida` database + user.
---
## Task 1: Provision the database
**Files:** none (server state).
- [ ] **Step 1: Create the database and a dedicated user**
Operator supplies the MariaDB root password. Choose a strong `APP_DB_PASS`.
Run:
```bash
mysql -u root -p -e "
CREATE DATABASE IF NOT EXISTS comiida CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER IF NOT EXISTS 'comiida'@'localhost' IDENTIFIED BY 'APP_DB_PASS';
GRANT ALL PRIVILEGES ON comiida.* TO 'comiida'@'localhost';
FLUSH PRIVILEGES;"
```
- [ ] **Step 2: Verify the user can connect to the empty DB**
Run: `mysql -u comiida -p'APP_DB_PASS' comiida -e "SELECT DATABASE();"`
Expected: prints `comiida`, no error.
- [ ] **Step 3: No commit** (server state, nothing in repo).
---
## Task 2: Installer service + `app:install` command
**Files:**
- Create: `app/Services/Installer.php`
- Create: `commands/InstallCommand.php`
- Modify: `console`
- [ ] **Step 1: Write `app/Services/Installer.php`**
```php
<?php
namespace App\Services;
use PDO;
use PDOException;
use RuntimeException;
/**
* Installer — provisions config.php + imports the skeleton schema.
* Shared by the CLI (app:install) and the (locked) web wizard.
* Uses filesystem-relative paths, never $_SERVER['DOCUMENT_ROOT'].
*/
class Installer
{
private string $baseDir; // == api/
public function __construct()
{
// app/Services/Installer.php -> up two levels -> api/
$this->baseDir = dirname(__DIR__, 2);
}
public function lockFile(): string
{
return $this->baseDir . '/system/.installed';
}
public function isInstalled(): bool
{
return is_file($this->lockFile());
}
public function testConnection(array $db): bool
{
try {
new PDO(
"mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4",
$db['user'], $db['pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return true;
} catch (PDOException $e) {
return false;
}
}
public function importSchema(array $db): void
{
$sqlFile = $this->baseDir . '/install/dump.sql';
if (!is_file($sqlFile)) {
throw new RuntimeException("Schema dump not found: {$sqlFile}");
}
$mysqli = new \mysqli($db['host'], $db['user'], $db['pass'], $db['name']);
if ($mysqli->connect_errno) {
throw new RuntimeException('DB connect failed: ' . $mysqli->connect_error);
}
if (!$mysqli->multi_query((string) file_get_contents($sqlFile))) {
throw new RuntimeException('Schema import failed: ' . $mysqli->error);
}
// Drain all result sets so the connection finishes cleanly.
while ($mysqli->more_results() && $mysqli->next_result()) { /* noop */ }
if ($mysqli->errno) {
throw new RuntimeException('Schema import error: ' . $mysqli->error);
}
$mysqli->close();
}
public function generateKey(int $bytes = 32): string
{
return bin2hex(random_bytes($bytes)); // hex only: safe inside single-quoted PHP
}
public function writeConfig(array $c): void
{
$e = fn($v) => addslashes((string) $v); // escape operator-provided values
$tpl = "<?php\n"
. "// Generated by SeedProject installer. DO NOT COMMIT (gitignored).\n"
. "define('URL', '" . $e($c['url']) . "');\n"
. "define('SITE_BASE', '/api');\n"
. "define('ASSETS', '/api/public/assets/');\n"
. "define('LIBS', 'core/');\n"
. "define('PROJECT_NAME', '" . $e($c['name']) . "');\n"
. "define('PROJECT_LOGO', '/api/public/assets/imgs/SeedProject.png');\n"
. "define('DEBUG', false);\n"
. "define('SECUREAPI', false);\n\n"
. "define('ADMIN_TOKEN', '" . $c['admin_token'] . "');\n"
. "define('ALLOWED_ORIGINS', '" . $e($c['url']) . "');\n\n"
. "define('EMAILUSER', 'ADDEMAILUSER');\n"
. "define('EMAILPASSWORD', 'ADDEMAILPASSWORD');\n"
. "define('EMAILHOST', 'ADDEMAILHOST');\n\n"
. "define('DB_TYPE', 'mysql');\n"
. "define('DB_HOST', '" . $e($c['db']['host']) . "');\n"
. "define('DB_NAME', '" . $e($c['db']['name']) . "');\n"
. "define('DB_USER', '" . $e($c['db']['user']) . "');\n"
. "define('DB_PASS', '" . $e($c['db']['pass']) . "');\n\n"
. "define('HASH_PASSWORD_KEY', '" . $c['hash_password_key'] . "');\n"
. "define('HASH_API_KEY', '" . $c['hash_api_key'] . "');\n"
. "define('TIMESTAMP', date('Y-m-d H:i:s'));\n"
. "date_default_timezone_set('America/New_York');\n\n"
. "\\Db::setConnectionInfo(DB_TYPE, DB_NAME, DB_USER, DB_PASS, DB_HOST);\n";
file_put_contents($this->baseDir . '/config.php', $tpl);
}
public function lock(): void
{
file_put_contents($this->lockFile(), date('c') . "\n");
}
/**
* Full install. $cfg = ['url','name','db'=>['host','name','user','pass']].
*/
public function run(array $cfg): void
{
if (!$this->testConnection($cfg['db'])) {
throw new RuntimeException('Database connection failed. Check credentials.');
}
$this->importSchema($cfg['db']);
$cfg['admin_token'] = $this->generateKey(24);
$cfg['hash_password_key'] = $this->generateKey(32);
$cfg['hash_api_key'] = $this->generateKey(32);
$this->writeConfig($cfg);
$this->lock();
}
}
```
- [ ] **Step 2: Write `commands/InstallCommand.php`**
```php
<?php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use App\Services\Installer;
class InstallCommand extends Command
{
protected function configure()
{
$this->setName('app:install')
->setDescription('Install the framework: import schema, write config, lock.')
->addOption('db-host', null, InputOption::VALUE_REQUIRED, 'DB host', 'localhost')
->addOption('db-name', null, InputOption::VALUE_REQUIRED, 'DB name')
->addOption('db-user', null, InputOption::VALUE_REQUIRED, 'DB user')
->addOption('db-pass', null, InputOption::VALUE_REQUIRED, 'DB password')
->addOption('url', null, InputOption::VALUE_REQUIRED, 'Site URL', 'https://www.comiida.com')
->addOption('name', null, InputOption::VALUE_REQUIRED, 'Project name', 'Comiida')
->addOption('force', null, InputOption::VALUE_NONE, 'Re-run even if already installed');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$installer = new Installer();
if ($installer->isInstalled() && !$input->getOption('force')) {
$output->writeln('<error>Already installed. Use --force to re-run.</error>');
return Command::FAILURE;
}
foreach (['db-name', 'db-user', 'db-pass'] as $req) {
if (!$input->getOption($req)) {
$output->writeln("<error>--{$req} is required.</error>");
return Command::FAILURE;
}
}
try {
$installer->run([
'url' => $input->getOption('url'),
'name' => $input->getOption('name'),
'db' => [
'host' => $input->getOption('db-host'),
'name' => $input->getOption('db-name'),
'user' => $input->getOption('db-user'),
'pass' => $input->getOption('db-pass'),
],
]);
} catch (\Throwable $e) {
$output->writeln('<error>Install failed: ' . $e->getMessage() . '</error>');
return Command::FAILURE;
}
$output->writeln('<info>Install complete. config.php written, schema imported, lock set.</info>');
return Command::SUCCESS;
}
}
```
- [ ] **Step 3: Register the command in `console`**
In `console`, add the `use` and registration (below the existing `GreetCommand` line):
```php
$application->add(new GreetCommand());
$application->add(new InstallCommand());
```
- [ ] **Step 4: Verify the command is discoverable**
Run: `php console list | grep app:install`
Expected: a line `app:install Install the framework...`
- [ ] **Step 5: Run the installer against the comiida DB**
Run (operator fills `APP_DB_PASS`):
```bash
php console app:install --db-host=localhost --db-name=comiida --db-user=comiida --db-pass='APP_DB_PASS' --url=https://www.comiida.com --name=Comiida
```
Expected: `Install complete...`
- [ ] **Step 6: Verify schema, config, and lock**
Run:
```bash
mysql -u comiida -p'APP_DB_PASS' comiida -e "SHOW TABLES;" | grep -E "api_auth|config|users"
php -r "require 'config.php'; echo DB_NAME.PHP_EOL; echo (strlen(ADMIN_TOKEN)>=48?'token-ok':'token-bad').PHP_EOL;"
test -f system/.installed && echo "locked"
```
Expected: tables listed; `comiida`, `token-ok`, `locked`.
- [ ] **Step 7: Commit**
```bash
git add app/Services/Installer.php commands/InstallCommand.php console
git commit -m "feat(api): CLI installer (app:install) — schema import, config, lock"
```
(Note: `config.php` and `system/.installed` are gitignored / not tracked.)
---
## Task 3: Migrations runner + `db:migrate`
**Files:**
- Create: `app/Services/Migrator.php`
- Create: `commands/MigrateCommand.php`
- Create: `db/migrations/001_create_metrics_placeholder.sql`
- Modify: `console`, `app/Services/Installer.php`
- [ ] **Step 1: Write `app/Services/Migrator.php`**
```php
<?php
namespace App\Services;
use PDO;
/** Applies ordered *.sql files from a migrations dir, tracked in a `migrations` table. */
class Migrator
{
private PDO $pdo;
private string $dir;
public function __construct(PDO $pdo, string $dir)
{
$this->pdo = $pdo;
$this->dir = $dir;
}
public function ensureTable(): void
{
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS `migrations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`filename` VARCHAR(255) NOT NULL UNIQUE,
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
/** @return string[] filenames already applied */
public function applied(): array
{
return $this->pdo->query("SELECT filename FROM `migrations`")->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
/** @return string[] absolute paths of pending migrations, in order */
public function pending(): array
{
$all = glob($this->dir . '/*.sql') ?: [];
sort($all);
$applied = $this->applied();
return array_values(array_filter($all, fn($p) => !in_array(basename($p), $applied, true)));
}
/** @return string[] filenames applied this run */
public function migrate(): array
{
$this->ensureTable();
$done = [];
foreach ($this->pending() as $path) {
$this->pdo->exec((string) file_get_contents($path));
$stmt = $this->pdo->prepare("INSERT IGNORE INTO `migrations` (filename) VALUES (?)");
$stmt->execute([basename($path)]);
$done[] = basename($path);
}
return $done;
}
}
```
- [ ] **Step 2: Write the first migration `db/migrations/001_create_metrics_placeholder.sql`**
```sql
-- Foundation smoke migration: proves the runner end-to-end.
-- (Real metrics tables land in the Metrics sub-project.)
CREATE TABLE IF NOT EXISTS `_foundation_check` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`note` VARCHAR(64) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- [ ] **Step 3: Write `commands/MigrateCommand.php`**
```php
<?php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use App\Services\Migrator;
class MigrateCommand extends Command
{
protected function configure()
{
$this->setName('db:migrate')
->setDescription('Apply pending SQL migrations from db/migrations/')
->addOption('status', null, InputOption::VALUE_NONE, 'Show applied/pending without applying');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$base = dirname(__DIR__); // api/
if (!is_file($base . '/config.php')) {
$output->writeln('<error>config.php missing — run app:install first.</error>');
return Command::FAILURE;
}
require_once $base . '/config.php'; // defines DB_* constants
$pdo = new PDO(
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER, DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$migrator = new Migrator($pdo, $base . '/db/migrations');
$migrator->ensureTable();
if ($input->getOption('status')) {
$output->writeln('Applied: ' . (implode(', ', $migrator->applied()) ?: '(none)'));
$output->writeln('Pending: ' . (implode(', ', array_map('basename', $migrator->pending())) ?: '(none)'));
return Command::SUCCESS;
}
$done = $migrator->migrate();
$output->writeln($done ? 'Applied: ' . implode(', ', $done) : 'Nothing to migrate.');
return Command::SUCCESS;
}
}
```
- [ ] **Step 4: Register in `console`**
```php
$application->add(new InstallCommand());
$application->add(new MigrateCommand());
```
- [ ] **Step 5: Wire migrations into the installer**
In `app/Services/Installer.php`, at the end of `run()` (after `$this->lock();`), append:
```php
// Apply migrations on top of the freshly imported baseline.
$pdo = new PDO(
"mysql:host={$cfg['db']['host']};dbname={$cfg['db']['name']};charset=utf8mb4",
$cfg['db']['user'], $cfg['db']['pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
(new Migrator($pdo, $this->baseDir . '/db/migrations'))->migrate();
```
Add `use PDO;` is already present; no new import needed (`Migrator` is same namespace).
- [ ] **Step 6: Verify migration status then apply**
Run:
```bash
php console db:migrate --status
php console db:migrate
mysql -u comiida -p'APP_DB_PASS' comiida -e "SELECT filename FROM migrations;"
```
Expected: status shows `001_...` pending → apply prints `Applied: 001_create_metrics_placeholder.sql` → the `migrations` table lists it. (`_foundation_check` table now exists.)
- [ ] **Step 7: Commit**
```bash
git add app/Services/Migrator.php commands/MigrateCommand.php db/migrations/001_create_metrics_placeholder.sql app/Services/Installer.php console
git commit -m "feat(api): SQL migrations runner (db:migrate) + wire into installer"
```
---
## Task 4: JSON base + public tier + `/api/health`
**Files:**
- Create: `app/Controllers/JsonController.php`
- Create: `app/Controllers/PublicController.php`
- Create: `public/controllers/health.php`
- [ ] **Step 1: Write `app/Controllers/JsonController.php`**
```php
<?php
namespace App\Controllers;
/** Base for JSON endpoints: response envelope + IP throttle. Extends core \Controller. */
class JsonController extends \Controller
{
/** Emit { ok, data, error } with the right HTTP status, then stop. */
protected function json($data = null, int $status = 200, ?array $error = null): void
{
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode([
'ok' => $error === null,
'data' => $data,
'error' => $error, // ['code' => ..., 'message' => ...] or null
]);
exit;
}
/**
* Returns true when the caller has EXCEEDED $max hits on $key within $window seconds.
* Reuses the api_requests table (no new table needed).
*/
protected function throttle(string $key, int $max, int $window): bool
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$count = (int) \Db::getValue(
"SELECT COUNT(*) FROM `api_requests`
WHERE `requesting_ip` = ? AND `request` = ?
AND `created_date` > (NOW() - INTERVAL ? SECOND)",
[$ip, $key, $window]
);
\Db::insert('api_requests', [
'requesting_ip' => $ip,
'request' => $key,
'service' => 'foundation',
'domainURI' => $_SERVER['HTTP_HOST'] ?? '',
'created_date' => date('Y-m-d H:i:s'),
]);
return $count >= $max;
}
}
```
- [ ] **Step 2: Write `app/Controllers/PublicController.php`**
```php
<?php
namespace App\Controllers;
/** Base for public (browser-callable) endpoints: origin allowlist + rate limit. */
class PublicController extends JsonController
{
/**
* Cross-origin browser requests send Origin; if present and not allowlisted -> false.
* Same-origin GET / non-browser callers omit Origin -> allowed.
*/
protected function checkOrigin(): bool
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin === '') {
return true; // same-origin GET or server-side caller
}
$allowed = array_filter(array_map('trim', explode(',', defined('ALLOWED_ORIGINS') ? ALLOWED_ORIGINS : '')));
if (empty($allowed)) {
return true; // not configured (dev)
}
$originHost = parse_url($origin, PHP_URL_HOST);
foreach ($allowed as $a) {
$host = parse_url($a, PHP_URL_HOST) ?: $a;
if ($originHost && strcasecmp($originHost, $host) === 0) {
header('Access-Control-Allow-Origin: ' . $origin);
return true;
}
}
return false;
}
/** Guard helper: enforce origin + rate limit, or emit the error envelope and stop. */
protected function guardPublic(string $key, int $max = 60, int $window = 60): void
{
if (!$this->checkOrigin()) {
$this->json(null, 403, ['code' => 'forbidden_origin', 'message' => 'Origin not allowed']);
}
if ($this->throttle($key, $max, $window)) {
$this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']);
}
}
}
```
- [ ] **Step 3: Write `public/controllers/health.php`**
```php
<?php
use App\Controllers\PublicController;
/** GET /api/health — public health round-trip (proves DB connectivity). */
class Health extends PublicController
{
public function index()
{
$this->guardPublic('health', 120, 60);
try {
$up = (int) \Db::getValue("SELECT 1");
$rows = (int) \Db::getValue("SELECT COUNT(*) FROM `config`");
} catch (\Throwable $e) {
$this->json(null, 500, ['code' => 'db_error', 'message' => DEBUG ? $e->getMessage() : 'Database unavailable']);
}
$this->json([
'db' => $up === 1 ? 'connected' : 'unknown',
'app' => defined('PROJECT_NAME') ? PROJECT_NAME : '',
'config_rows' => $rows,
'time' => date('c'),
]);
}
}
```
- [ ] **Step 4: Verify the health endpoint returns live DB JSON**
Run: `curl -sk https://127.0.0.1/api/health -H "Host: www.comiida.com"`
Expected: `{"ok":true,"data":{"db":"connected","app":"Comiida","config_rows":<n>,"time":"..."},"error":null}`
- [ ] **Step 5: Verify rate limiting**
Run: `for i in $(seq 1 130); do curl -s -o /dev/null -w "%{http_code} " -k https://127.0.0.1/api/health -H "Host: www.comiida.com"; done; echo`
Expected: `200` responses turning into `429` after ~120 within the minute.
- [ ] **Step 6: Commit**
```bash
git add app/Controllers/JsonController.php app/Controllers/PublicController.php public/controllers/health.php
git commit -m "feat(api): JSON base + public tier (origin+rate-limit) + /api/health"
```
---
## Task 5: Privileged tier + `/api/admin/ping`
**Files:**
- Create: `app/Controllers/ApiController.php`
- Create: `public/controllers/admin.php`
- [ ] **Step 1: Write `app/Controllers/ApiController.php`**
```php
<?php
namespace App\Controllers;
/** Base for privileged endpoints: bearer ADMIN_TOKEN or api_auth API key. */
class ApiController extends JsonController
{
protected ?array $apiUser = null; // set when an api_auth key authenticates
/** Extract a token from Authorization: Bearer, ?apikey=, or X-Api-Key. */
private function bearer(): string
{
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
if (stripos($h, 'Bearer ') === 0) {
return trim(substr($h, 7));
}
return $_SERVER['HTTP_X_API_KEY'] ?? ($_GET['apikey'] ?? '');
}
/** Enforce authentication; emits 401 and stops on failure. Also basic per-minute throttle. */
protected function requireAuth(int $max = 120, int $window = 60): void
{
$token = $this->bearer();
if ($token === '') {
$this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Missing bearer token']);
}
// First-party admin token (constant-time compare).
if (defined('ADMIN_TOKEN') && hash_equals(ADMIN_TOKEN, $token)) {
if ($this->throttle('admin', $max, $window)) {
$this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']);
}
return;
}
// Programmatic api_auth key.
$row = \Db::getRow(
"SELECT `id`, `userid`, `active` FROM `api_auth` WHERE `apikey` = ? LIMIT 1",
[$token]
);
if (!$row || (int) $row['active'] !== 1) {
$this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Invalid API key']);
}
$this->apiUser = $row;
if ($this->throttle('apikey:' . $row['id'], $max, $window)) {
$this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']);
}
// NOTE: monthly/plan quota enforcement (api_plans/api_usage) is deferred to a later sub-project.
}
}
```
- [ ] **Step 2: Write `public/controllers/admin.php`**
```php
<?php
use App\Controllers\ApiController;
/** GET /api/admin/ping — privileged round-trip. */
class Admin extends ApiController
{
public function ping()
{
$this->requireAuth();
$this->json([
'pong' => true,
'auth' => $this->apiUser ? 'apikey' : 'admin_token',
'time' => date('c'),
]);
}
}
```
- [ ] **Step 3: Verify unauthorized is rejected**
Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/admin/ping -H "Host: www.comiida.com"`
Expected: `401`
- [ ] **Step 4: Verify the admin token authenticates**
Run:
```bash
TOKEN=$(php -r "require 'config.php'; echo ADMIN_TOKEN;")
curl -sk https://127.0.0.1/api/admin/ping -H "Host: www.comiida.com" -H "Authorization: Bearer $TOKEN"
```
Expected: `{"ok":true,"data":{"pong":true,"auth":"admin_token","time":"..."},"error":null}`
- [ ] **Step 5: Commit**
```bash
git add app/Controllers/ApiController.php public/controllers/admin.php
git commit -m "feat(api): privileged tier (bearer/api_auth) + /api/admin/ping"
```
---
## Task 6: Security hardening — lock the installer
**Files:**
- Modify: `install/controllers/index.php`
- Modify: Apache vhost `/www/server/panel/vhost/apache/comiida.com.conf`
- [ ] **Step 1: Make the web installer refuse when locked & delegate to `Installer`**
At the top of `installation()` in `install/controllers/index.php`, before any work, insert:
```php
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
$installer = new \App\Services\Installer();
if ($installer->isInstalled()) {
http_response_code(403);
die('Already installed. Remove system/.installed to reinstall.');
}
if (!$_POST) { header('Location: /api/install/'); die(); }
$installer->run([
'url' => 'https://www.comiida.com',
'name' => 'Comiida',
'db' => [
'host' => $_POST['dbloca'],
'name' => $_POST['dbname'],
'user' => $_POST['dbuser'],
'pass' => $_POST['dbpass'],
],
]);
header('Location: /api/install/i/complete');
return;
```
(This replaces the old body that used `$_SERVER['DOCUMENT_ROOT']` and wrote a `seedproject.com` config. The rest of the method below can be removed.)
- [ ] **Step 2: Extend the Apache denies**
In `/www/server/panel/vhost/apache/comiida.com.conf`, update the existing `DirectoryMatch` (added when `/api` was mounted) to also cover `install`, `db`, and add a `.installed` file deny. Replace the block with:
```apache
# Deny web access to framework internals (loaded server-side only)
<DirectoryMatch "^/www/wwwroot/comiida\.com/api/(vendor|core|app|system|commands|db|install|\.memory|\.reference_files)(/|$)">
Require all denied
</DirectoryMatch>
<Files ".installed">
Require all denied
</Files>
```
- [ ] **Step 3: Apply and reload**
Run: `/www/server/apache/bin/httpd -t && /www/server/apache/bin/httpd -k graceful`
Expected: `Syntax OK`, reload succeeds.
- [ ] **Step 4: Verify the installer is no longer web-reachable**
Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/install/ -H "Host: www.comiida.com"`
Expected: `403`
- [ ] **Step 5: Verify `/api/health` still works (denies didn't over-reach)**
Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/health -H "Host: www.comiida.com"`
Expected: `200`
- [ ] **Step 6: Commit**
```bash
git add install/controllers/index.php
git commit -m "feat(api): lock installer, delegate to Installer service, deny install/db over HTTP"
```
(Apache vhost is outside the repo — not committed.)
---
## Task 7: Astro round-trip proof page
**Files:**
- Create: `app/src/pages/api-health-test.astro`
- [ ] **Step 1: Write the test page**
```astro
---
// Static page; the fetch runs client-side, same-origin, against the PHP API.
---
<html lang="en">
<head><meta charset="utf-8" /><title>API health test</title></head>
<body style="font-family: system-ui; padding: 2rem;">
<h1>SeedProject /api health</h1>
<pre id="out">loading…</pre>
<script>
fetch("/api/health", { credentials: "same-origin" })
.then((r) => r.json())
.then((j) => { document.getElementById("out").textContent = JSON.stringify(j, null, 2); })
.catch((e) => { document.getElementById("out").textContent = "ERROR: " + e; });
</script>
</body>
</html>
```
- [ ] **Step 2: Build the Astro site**
Run: `cd /www/wwwroot/comiida.com/app && npm run build`
Expected: build succeeds; `public/api-health-test/index.html` (or `public/api-health-test.html`) produced.
- [ ] **Step 3: Verify the page loads and the fetch target resolves**
Run: `curl -sk https://127.0.0.1/api-health-test -H "Host: www.comiida.com" | grep -c "/api/health"`
Expected: `1` (the page references the endpoint). Loading it in a browser shows the live JSON envelope with `db: "connected"`.
- [ ] **Step 4: Commit**
```bash
cd /www/wwwroot/comiida.com
git add app/src/pages/api-health-test.astro
git commit -m "feat(api): Astro page proving browser->PHP->MariaDB health round-trip"
```
---
## Self-review notes
- **Spec coverage:** Installer (§1) → Task 2/6; migrations (§2) → Task 3; two-tier auth + envelope (§3) → Task 4/5; health round-trip (§4) → Task 4/5/7; security hardening (§5) → Task 6; DB provisioning → Task 1. All spec sections mapped.
- **Deliberate scope trim (YAGNI):** full `api_plans`/`api_usage` monthly-quota enforcement is deferred (flagged inline in `ApiController::requireAuth`); the foundation ships bearer/api_auth verification + per-minute throttle + request logging, which is enough to prove the privileged tier.
- **Type consistency:** `Installer::run(['url','name','db'=>[...]])` shape is used identically in InstallCommand (Task 2), Installer migration wiring (Task 3), and the web wizard (Task 6). `Migrator($pdo, $dir)` signature consistent across Task 3 and the installer. `json()/throttle()/guardPublic()/requireAuth()` names consistent across controllers.
- **Open item:** operator provides the MariaDB root + app DB password (Task 1) and should still rotate/delete the vestigial `api/.env`.

209
api/.memory/foundation.md Normal file
View file

@ -0,0 +1,209 @@
# Foundation — SeedProject as a per-project Astro backend
**Status:** Design (approved, not yet implemented) · **Date:** 2026-07-04
**Related:** [documentation.md](documentation.md) (framework internals) · [llm.md](llm.md) (LLM layer) · [changelog.md](changelog.md)
---
## Context & goal
SeedProject (this `api/` framework) is mounted at `comiida.com/api/` and boots
(see the 2026-07-04 changelog). The goal now: make it a **solid, reusable backend
platform** that a static Astro frontend calls to reach a database, run agents,
trigger CLI processes, and record metrics.
That vision is four subsystems on a shared base. This doc specifies **only the
Foundation** (sub-project #1) — the database + a secure, reproducible, reusable
request round-trip. Metrics, agents, and CLI *business* commands are separate
specs built on top of it.
### Locked decisions
| Question | Decision |
|---|---|
| Reuse model | **Per-project copy** — each Astro site gets its own `api/` install + own DB. A drop-in starter, fully isolated. |
| Auth | **Two-tier** — public (origin + rate-limit) for browser beacons; privileged (bearer token / API key) for reads/admin. |
| Schema mgmt | **Versioned SQL migrations via CLI**`install/dump.sql` baseline + `db/migrations/NNN_*.sql` applied by `php console db:migrate`. |
| Install | **CLI-first** (`php console app:install`); the existing web wizard is fixed but hard-locked. |
### Deployment facts (this project)
- Web server: **Apache** (`/www/server/apache`), DocumentRoot = `.../public` (static Astro build). `/api` is served via an `Alias``.../api` with a php-fpm 8.3 handler and front-controller rewrite. (nginx conf in the BT panel is inert.)
- DB engine: **MariaDB 10.11** on `/tmp/mysql.sock` (BT-managed). Foundation provisions a dedicated `comiida` database + user.
- Frontend calls are **browser-side, same-origin** `fetch("/api/…")` (comiida is static; there is no Astro SSR). This is why the public tier is origin/rate-limited rather than server-secret-authenticated.
---
## Architecture
```
Browser (static Astro page)
│ fetch("/api/health") same-origin
Apache :443 ──Alias /api──▶ api/index.php ──▶ core/Bootstrap ──▶ Router
│ │
│ (php-fpm 8.3 via /tmp/php-cgi-83.sock) ┌────────────────┴───────────────┐
│ ▼ ▼
│ PublicController ApiController
│ (origin + rate limit) (bearer / api_auth
│ │ + plan limits + logging)
│ ▼ ▼
│ App logic ───────▶ Db helper ───▶ MariaDB (comiida)
JSON envelope { ok, data, error }
```
The Foundation adds **five units**, each independently understandable/testable:
1. **Installer** — provisions DB + config (CLI + locked web wizard share one service).
2. **Migrations runner** — versioned schema evolution.
3. **Two-tier auth**`PublicController` + `ApiController` base classes.
4. **Health round-trip** — a real end-to-end proof endpoint.
5. **Security hardening** — Apache denies, install lock, crypto keys.
---
## 1. Install (CLI-first)
**Problem with the shipped installer** (`install/controllers/index.php`): it reads
`dump.sql`/writes `config.php` via `$_SERVER['DOCUMENT_ROOT']` (wrong under the
`/api` Alias — that's comiida's `public/`); it generates a config with `SITE_BASE '/'`,
`ASSETS '/public/assets/'`, `seedproject.com` (would clobber the correct `/api`
config); it is web-reachable and re-runnable; and `getName()` uses non-crypto
`rand()` over an alphabet containing `'`, `\`, `/` (a quote breaks the single-quoted
`config.php` → PHP syntax error).
**Design:**
- **`app/Services/Installer.php`** — one service both entrypoints call (DRY). Uses
`dirname(__DIR__, 2)` base paths, never `DOCUMENT_ROOT`. Responsibilities:
1. Validate + test DB connection (PDO).
2. Import `install/dump.sql` (the skeleton).
3. Generate keys with `bin2hex(random_bytes(32))` (crypto-safe; hex only — no quote bug).
4. Write `api/config.php` with the **correct `/api` constants** (`SITE_BASE=/api`,
`ASSETS=/api/public/assets/`, `URL`, `PROJECT_NAME` from args/env; DB creds).
5. Record the baseline as applied, then run pending migrations.
6. Write install lock `api/system/.installed`.
- **`commands/InstallCommand.php`** — `php console app:install` (Symfony Console,
registered in `console` next to `GreetCommand`). Reads creds from options
(`--db-host --db-name --db-user --db-pass --url --name`) or interactive prompt;
refuses to run if `.installed` exists unless `--force`.
- **Web wizard** (`install/`) — refactored to call `Installer` (fixes its path bugs);
refuses to run when `.installed` exists; denied at the Apache layer by default.
Config stays as `config.php` (framework-native, already gitignored) — no `.env`
loader is introduced (YAGNI). The stray CreditPullEngine `.env` remains vestigial and
should be deleted/rotated by the operator.
## 2. Schema & migrations
- `install/dump.sql` = **baseline** (imported once). Tables already provided:
`users*`, `roles`/`permissions`/`role_perm` (RBAC), `org*` (orgs/profiles),
`api_auth`/`users_api` (API keys), `api_plans` (rate limits), `api_requests`
(request log), `api_usage` (daily/monthly/yearly counters), `config` (key/value).
- **`db/migrations/NNN_description.sql`** — ordered, forward-only SQL files.
- **`migrations`** tracking table (`id, filename, applied_at`) — created by the
installer; baseline recorded so it is never re-run.
- **`commands/MigrateCommand.php`** — `php console db:migrate` applies unapplied
files in filename order, each in a transaction where the DDL allows; records each.
`php console db:migrate --status` lists applied/pending.
## 3. Two-tier auth + response contract
Reuses the existing tables — no duplication.
- **`ApiController`** (base, privileged) — verifies a bearer token: either the
`ADMIN_TOKEN` (config constant; same token pattern as `/devconsole` & `/admin`)
for first-party/admin calls, **or** an `api_auth.apikey` for programmatic
consumers. On an API key: check `active`, enforce `api_plans.limit_minute` /
`limit_monthly` against `api_usage`, and log to `api_requests`. Failures →
`401` (missing/invalid) or `429` (over limit).
- **`PublicController`** (base, public) — for browser beacons. Verifies
`Origin`/`Referer` against a config **`ALLOWED_ORIGINS`** allowlist, applies
**IP-based rate limiting**, requires no user key. Failures → `403` (bad origin)
or `429` (flood). Emits same-origin CORS headers.
- **Response envelope** — a shared `json($data, $status)` helper on the base
`Controller` returns `{ "ok": bool, "data": …, "error": { "code", "message" } }`
with the matching HTTP status.
## 4. Health round-trip (the demonstrable slice)
- **`GET /api/health`** (public) → runs a **real** DB query (`SELECT 1` + read one
`config` row) → `{ ok:true, data:{ db:"connected", app, version, time } }`.
- **`GET /api/admin/ping`** (privileged) → echoes authenticated context.
- A small client-side `fetch("/api/health")` on an Astro test page renders the
result — proving **browser → Apache → php-fpm → MariaDB → JSON** end to end.
## 5. Security hardening
- Apache `<DirectoryMatch>` denies extended to `install/` (once `.installed` exists),
`db/`, and the `.installed` lock. Existing denies (`vendor|core|app|system|commands|
.memory|.reference_files`, dotfiles, `config.php`, `composer.*`) stay.
- `config.php` gitignored, written with crypto-safe keys.
- Public endpoints origin-gated + rate-limited.
- Operator TODO: rotate/delete the vestigial `api/.env` (live CreditPullEngine
secrets + a GitHub PAT).
---
## Data flow — a public request
1. Browser `GET /api/health` (same-origin, no credentials).
2. Apache Alias → `api/index.php` → Bootstrap → Router → `Health` controller
(extends `PublicController`).
3. `PublicController` checks `Origin``ALLOWED_ORIGINS`, checks IP rate limit.
4. Controller queries `Db` (lazy PDO connect to MariaDB), builds payload.
5. `json()` emits `{ ok, data }` + `200` (or `403`/`429`/`500`).
## Error handling
- All controller output goes through `json()`; no raw echo. HTTP status always set.
- DB/PDO exceptions caught → `{ ok:false, error:{ code:"db_error", … } }` + `500`
(detail hidden unless `DEBUG`). `system/ErrorHandler.php` remains the backstop.
- Auth failures return typed codes: `unauthorized` (401), `forbidden_origin` (403),
`rate_limited` (429).
## File inventory
**New**
- `app/Services/Installer.php`
- `commands/InstallCommand.php`, `commands/MigrateCommand.php`
- `app/Controllers/PublicController.php`, `app/Controllers/ApiController.php`
- `public/controllers/health.php` (public), `public/controllers/admin.php` (privileged)
- `db/migrations/` (+ a first example migration)
- `api/system/.installed` (generated at install)
**Modified**
- `console` — register `InstallCommand`, `MigrateCommand`
- `install/controllers/index.php` — delegate to `Installer` (path-bug fix, lock check)
- `core/Controller.php` (or a base) — add `json()` helper
- `config.php` — add `ADMIN_TOKEN`, `ALLOWED_ORIGINS` constants (generated by installer)
- Apache vhost (`/www/server/panel/vhost/apache/comiida.com.conf`) — extend denies
**Server (outside repo)**
- MariaDB: create `comiida` database + dedicated user.
## Verification
1. `php console app:install --db-… --url=https://www.comiida.com --name=Comiida`
→ tables created, `config.php` written with crypto keys, `.installed` present.
2. `php console db:migrate --status` → baseline + migrations applied.
3. `curl https://www.comiida.com/api/health``200`, `db:"connected"`.
4. From a disallowed `Origin``403`; flood → `429`.
5. `curl /api/admin/ping` no token → `401`; with `ADMIN_TOKEN``200`.
6. `curl /api/install/` after lock → denied.
7. Main Astro site (`/`) unaffected.
## Out of scope (future sub-projects, each its own spec)
- **Metrics** — event tables + `collect` beacon + admin dashboard.
- **Runtime agents/skills** — LLM agent endpoints (builds on [llm.md](llm.md)).
- **Business CLI commands** — cron/process runners.
The Foundation delivers the CLI *infrastructure* (`app:install`, `db:migrate`) and the
secure round-trip those three build on.
## Open items for the operator
- DB name/user/password for the `comiida` database (BT panel or root creds).
- Decision to rotate + delete `api/.env`.

313
api/.memory/llm.md Normal file
View file

@ -0,0 +1,313 @@
# LLM Integration Documentation
## Architecture
```
app/LLM/
├── LLMProvider.php — Abstract base class (contract for all providers)
├── LLMManager.php — Factory: loads org config from DB, returns provider instance
├── svcOpenAI.php — OpenAI (fully implemented)
├── svcAnthropic.php — Anthropic/Claude (fully implemented)
├── svcGemini.php — Google Gemini (fully implemented)
├── svcMistral.php — Mistral AI (fully implemented)
└── svcDeepSeek.php — DeepSeek (fully implemented)
```
## Database Storage — sp_orgs_meta
LLM configs are stored per-org in `sp_orgs_meta`:
| column | description |
|------------|------------------------------------|
| orgid | Foreign key to sp_orgs |
| keyval | Provider key (e.g. `llmOpenAI`) |
| metval | JSON config blob |
| active | 1 = enabled, 0 = disabled |
### keyval names
| Provider | keyval |
|------------|----------------|
| OpenAI | `llmOpenAI` |
| Anthropic | `llmAnthropic` |
| Gemini | `llmGemini` |
| Mistral | `llmMistral` |
| DeepSeek | `llmDeepSeek` |
### metval JSON structure (all providers)
```json
{
"secretKey": "sk-...",
"model": "gpt-4o",
"max_tokens": 4096,
"temperature": 0.7
}
```
## LLMManager — Factory Class
```php
use App\LLM\LLMManager;
// Get a configured, ready-to-use provider instance
$llm = LLMManager::forOrg($orgId, 'openai');
// List all configured providers for an org
$providers = LLMManager::getAvailableProviders($orgId);
// returns: ['openai', 'gemini']
// Save or update a provider config
LLMManager::saveOrgConfig($orgId, 'openai', [
'secretKey' => 'sk-...',
'model' => 'gpt-4o',
'max_tokens' => 4096,
'temperature' => 0.7,
]);
```
## LLMProvider — Base Class Interface
All providers expose the same methods:
```php
// Send a prompt, get full response
$response = $llm->sendPrompt(array $messages, array $options = []);
// Returns: ['success' => bool, 'content' => string, 'usage' => array, 'error' => string]
// Stream a prompt, callback per chunk
$result = $llm->streamPrompt(array $messages, array $options, callable $callback);
// Returns: ['success' => bool, 'error' => string]
// Get available models
$models = $llm->getModels();
// Returns: [['id' => 'gpt-4o', 'label' => 'GPT-4o'], ...]
// Test API key + connectivity
$status = $llm->testConnection();
// Returns: ['success' => bool, 'latency_ms' => int, 'error' => string]
```
### Fluent setters (chainable)
```php
$llm->setModel('gpt-4o')
->setMaxTokens(2048)
->setTemperature(0.5)
->setSystemPrompt('You are a helpful assistant.')
->setTimeout(60);
```
## Usage Examples (PHP)
### Basic chat
```php
$llm = LLMManager::forOrg($orgId, 'openai');
$response = $llm->sendPrompt([
['role' => 'user', 'content' => 'Summarize this contract: ...']
]);
if ($response['success']) {
echo $response['content'];
// $response['usage'] = ['prompt_tokens' => 120, 'completion_tokens' => 80, 'total_tokens' => 200]
}
```
### With system prompt and custom options
```php
$llm = LLMManager::forOrg($orgId, 'anthropic');
$llm->setSystemPrompt('You are a legal document assistant.');
$response = $llm->sendPrompt(
[['role' => 'user', 'content' => 'Draft an NDA for two parties.']],
['model' => 'claude-sonnet-4-6', 'max_tokens' => 2048, 'temperature' => 0.3]
);
```
### Streaming (PHP — CLI or long-running process)
```php
$llm = LLMManager::forOrg($orgId, 'gemini');
$llm->streamPrompt(
[['role' => 'user', 'content' => 'Write a report on...']],
[],
function(string $chunk) {
echo $chunk;
flush();
}
);
```
### Multi-turn conversation
```php
$messages = [
['role' => 'user', 'content' => 'My name is Carlos.'],
['role' => 'assistant', 'content' => 'Nice to meet you, Carlos!'],
['role' => 'user', 'content' => 'What is my name?'],
];
$response = $llm->sendPrompt($messages);
```
## API Endpoints — appi/controllers/llm.php
All endpoints are authenticated via `Auth::API()`.
Internal AJAX requests (X-Requested-With: XMLHttpRequest) pass through automatically.
External requests require `?apikey=` param.
### POST /appi/llm/chat
Full response.
```json
// Request
{
"provider": "openai",
"org_id": 100,
"messages": [{"role": "user", "content": "Hello"}],
"options": {"model": "gpt-4o", "max_tokens": 1024, "system_prompt": "..."}
}
// Response
{
"success": true,
"provider": "openai",
"content": "Hello! How can I help you?",
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18}
}
```
### POST /appi/llm/stream
SSE streaming. Each chunk sent as:
```
event: chunk
data: {"chunk":"partial text here"}
event: done
data: "[DONE]"
event: error
data: {"error":"something went wrong"}
```
### GET /appi/llm/models?provider=openai&org_id=100
```json
{
"success": true,
"provider": "openai",
"models": [{"id": "gpt-4o", "label": "GPT-4o"}, ...]
}
```
### GET /appi/llm/providers?org_id=100
```json
{
"success": true,
"org_id": 100,
"providers": ["openai", "gemini"]
}
```
### POST /appi/llm/test
```json
// Request
{"provider": "openai", "org_id": 100}
// Response
{"success": true, "provider": "openai", "latency_ms": 342, "error": ""}
```
## Frontend JS Client — public/assets/js/llm-client.js
Load in any page that needs LLM functionality:
```php
$this->JavaScript[] = '/public/assets/js/llm-client.js';
$this->view->JavaScript = $this->JavaScript;
```
### LLMClient.chat()
```js
const res = await LLMClient.chat({
provider: 'openai',
orgId: 100,
messages: [{ role: 'user', content: 'Hello' }],
options: { model: 'gpt-4o', system_prompt: 'You are helpful.' }
});
console.log(res.content);
```
### LLMClient.stream()
```js
const output = document.getElementById('output');
await LLMClient.stream({
provider: 'anthropic',
orgId: 100,
messages: [{ role: 'user', content: 'Write a report on...' }],
options: { model: 'claude-sonnet-4-6' },
onChunk: (chunk) => { output.innerHTML += chunk; },
onDone: () => { console.log('Stream complete'); },
onError: (err) => { console.error('Error:', err); }
});
```
### LLMClient.getModels()
```js
const { models } = await LLMClient.getModels('openai', 100);
// models = [{ id: 'gpt-4o', label: 'GPT-4o' }, ...]
```
### LLMClient.getProviders()
```js
const { providers } = await LLMClient.getProviders(100);
// providers = ['openai', 'gemini']
```
### LLMClient.test()
```js
const { success, latency_ms, error } = await LLMClient.test('openai', 100);
```
## Provider API Differences (internals)
| Provider | Auth Header | System Prompt | Role names | Quirks |
|------------|-------------------------|-----------------------|-------------------------|---------------------------------|
| OpenAI | `Authorization: Bearer` | `role: system` msg | user / assistant | No temperature on o1/o3 models |
| Anthropic | `x-api-key` | Top-level `system` key| user / assistant | Requires `anthropic-version` header |
| Gemini | `?key=` query param | `systemInstruction` | user / model | Messages use `parts: [{text}]` |
| Mistral | `Authorization: Bearer` | `role: system` msg | user / assistant | None |
| DeepSeek | `Authorization: Bearer` | `role: system` msg | user / assistant | No temperature on R1 (reasoner) |
## Available Models
### OpenAI
- `gpt-4o` — GPT-4o (default)
- `gpt-4o-mini` — GPT-4o Mini
- `gpt-4-turbo` — GPT-4 Turbo
- `o1` — o1
- `o1-mini` — o1 Mini
- `o3-mini` — o3 Mini
### Anthropic
- `claude-sonnet-4-6` — Claude Sonnet 4.6 (default)
- `claude-opus-4-6` — Claude Opus 4.6
- `claude-haiku-4-5-20251001` — Claude Haiku 4.5
### Gemini
- `gemini-2.5-flash` — Gemini 2.5 Flash (default)
- `gemini-2.5-flash-lite` — Gemini 2.5 Flash Lite
- `gemini-2.0-flash` — Gemini 2.0 Flash
- `gemini-1.5-pro` — Gemini 1.5 Pro
### Mistral
- `mistral-large-latest` — Mistral Large (default)
- `mistral-small-latest` — Mistral Small
- `codestral-latest` — Codestral
- `open-mistral-nemo` — Mistral Nemo
### DeepSeek
- `deepseek-chat` — DeepSeek Chat V3 (default)
- `deepseek-reasoner` — DeepSeek Reasoner R1
## Adding a New Provider
1. Create `app/LLM/svcNewProvider.php` extending `LLMProvider`
2. Implement: `__construct(array $config)`, `sendPrompt()`, `streamPrompt()`, `getModels()`, `testConnection()`
3. Register in `LLMManager.php`:
- Add to `$providers` array: `'newprovider' => svcNewProvider::class`
- Add to `$metaKeys` array: `'newprovider' => 'llmNewProvider'`
4. Add JSON config row to `sp_orgs_meta` with `keyval = 'llmNewProvider'`

158
api/app/Components/Role.php Normal file
View file

@ -0,0 +1,158 @@
<?php namespace App\Components;
use Model as Model;
class Role extends Model {
public function __construct() {
parent::__construct();
}
public static function deniedAction($permission, $controller) {
$controller = strtolower($controller);
foreach($permission as $permContrller => $PerMethod) {
if($permContrller == $controller) {
$AllowAccess = 1;
break;
}
}
if($AllowAccess == 1) {
return true;
} else {
print_array('Deny Access ' . $controller);
}
}
public static function AccessGranted($permission, $method) {
//print_array($permission);
if($permission[0] == "*") { return true; } else {
if (in_array($method, $permission)) {
return true;
} else {
die('Access Denied - ' . $method);
}
}
}
//////////////////////// Manage Role Application ////////////////////////////
public function Group() {
$Return = $this->db->select("SELECT COUNT(*) AS accounts, a.role_id, b.role_name AS RoleName, b.createdate AS CreateDate FROM usergen a JOIN roles b ON a.role_id=b.role_id GROUP BY a.role_id");
return $Return;
}
public function getPerms($data=""){
if($data){
$Search .= "WHERE 1";
foreach($data as $key => $val) :
if(!$val) continue;
$string = strtolower($val);
$Search .= " AND {$key} LIKE '%{$string}%'";
endforeach;
}
$Return = $this->db->select("SELECT * FROM permissions {$Search} ORDER BY perm_controller ASC");
// sendlog("SELECT * FROM permissions {$Search} ORDER BY perm_controller ASC");
return $Return;
}
public function insertPerm($data){
$data['perm_controller'] = strtolower($data['perm_controller']);
$data['perm_action'] = strtolower($data['perm_action']);
$Return = $this->db->insert('permissions', $data);
// sendlog($Return);
return $Return;
}
public function deletePerm($id) {
$Return = $this->db->query("DELETE FROM permissions WHERE perm_id='{$id}'");
return $Return;
}
public function updatePerm($data, $pid){
$Return = $this->db->update('permissions', $data, "perm_id='{$pid}'");
return $Return;
}
// insert a new role
public function insertRole($data) {
$rdata['role_name'] = $data['groupname'];
$rdata['controller'] = $data['grouprole'];
$Return = $this->db->insert('roles', $rdata);
$getID = json_decode($Return);
foreach($data['permission'] as $permid) :
$permData['role_id']=$getID->ID ;
$permData['perm_id']=$permid;
$RolePerms = $this->db->insert('role_perm', $permData);
endforeach;
return $Return;
}
// insert array of roles for specified user id
public function insertUserRoles($userid, $roles) {
$sql = "INSERT INTO user_role (userid, role_id) VALUES (:userid, :role_id)";
$sth = $GLOBALS["DB"]->prepare($sql);
$sth->bindParam(":userid", $userid, PDO::PARAM_STR);
$sth->bindParam(":role_id", $role_id, PDO::PARAM_INT);
foreach ($roles as $role_id) {
$sth->execute();
}
return true;
}
// delete array of roles, and all associations
public function deleteRoles($roles) {
$sql = "DELETE t1, t2, t3 FROM roles as t1
JOIN user_role as t2 on t1.role_id = t2.role_id
JOIN role_perm as t3 on t1.role_id = t3.role_id
WHERE t1.role_id = :role_id";
$sth = $GLOBALS["DB"]->prepare($sql);
$sth->bindParam(":role_id", $role_id, PDO::PARAM_INT);
foreach ($roles as $role_id) {
$sth->execute();
}
return true;
}
// delete ALL roles for specified user id
public function deleteUserRoles($userid) {
$sql = "DELETE FROM user_role WHERE userid = :userid";
$sth = $GLOBALS["DB"]->prepare($sql);
return $sth->execute(array(":userid" => $userid));
}
// check if a user has a specific role
public function hasRole($role_name) {
return isset($this->roles[$role_name]);
}
public static function createRole(){
die('testing');
}
public function roleType(){
$SQL = "SELECT role_id, role_name FROM roles";
$Return = $this->db->select($SQL);
return $Return;
}
} // End Class

View file

@ -0,0 +1,31 @@
<? namespace App\Components;
use Model as Model;
class Template extends Model
{
public function __construct() {
parent::__construct();
}
public function updateUser() {
echo "UpdateUser";
}
public function deleteUser() {
echo "delete User";
}
public function getUsers() {
echo "Retrieve Users";
}
}

View file

@ -0,0 +1,31 @@
<?php
use App\Components\User;
/**
* AppController SaaS-level base controller.
*
* All UI controllers extend this instead of the core Controller directly.
* Owns app-wide concerns: session user profile injection, shared view data, etc.
* Core framework files (core/Controller.php) are never modified.
*/
class AppController extends Controller {
function __construct() {
parent::__construct();
// Inject the authenticated user's full profile into every view
if (!empty($_SESSION['login']['userid'])) {
$profile = User::profile($_SESSION['login']['userid']);
$this->view->UserProfile = $profile;
// Build tokenized slug for account settings link: "first-last.encryptedId"
if ($profile) {
$namePart = strtolower(trim(($profile['fname'] ?? '') . '-' . ($profile['lname'] ?? ''), '-'));
$namePart = preg_replace('/[^a-z0-9\-]/', '', $namePart);
$token = Functions::encryptData((string)$profile['userid'], HASH_PASSWORD_KEY);
$this->view->UserSlug = $namePart . '.' . $token;
}
}
}
}

82
api/app/Core/Email.php Normal file
View file

@ -0,0 +1,82 @@
<?php namespace App\Core;
//Import PHPMailer classes into the global namespace
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
use Model as Model;
class Email extends Model
{
public $SendToEmail;
public $BodyMessage;
public $Template;
public $Subject;
public $EmailParam;
public $Attachment;
public $ccEmail;
public function SendEmail() {
$mail = new PHPMailer(true);
try {
//Server settings
$mail->SMTPDebug = 0; // Enable verbose debug output
$mail->isSMTP(); // Set mailer to use SMTP
$mail->Host = EMAILHOST; // Specify main and backup SMTP servers
$mail->SMTPAuth = true; // Enable SMTP authentication
$mail->Username = EMAILUSER; // SMTP username
$mail->Password = EMAILPASS; // SMTP password
$mail->SMTPSecure = 'tls'; // Enable TLS encryption, `ssl` also accepted
$mail->Port = 587; // TCP port to connect to
//Recipients
$mail->setFrom('noreply@snoopi.io', 'Snoopi.io');
//Set an alternative reply-to address
$mail->addReplyTo('support@snoopi.io', 'Snoopi.io');
#TODO: Need to have it loop multiple email addresses
$mail->AddBCC('carlosja80@gmail.com ', 'Carlos Arias'); // Add a recipient
if($this->ccEmail) {
foreach($this->ccEmail as $ccdemail) {
$mail->AddCC(trim($ccdemail));
}
}
$mail->addAddress(trim($this->SendToEmail)); // Name is optional
//Attachments
if($this->Attachment) $mail->addAttachment($this->Attachment);
//$mail->addAttachment('/tmp/image.jpg', 'new.jpg'); // Optional name
//Content
$mail->isHTML(true); // Set email format to HTML
$mail->Subject = $this->Subject;
$mail->Body = $this->BodyMessage;
$mail->AltBody = 'This is the body in plain text for non-HTML mail clients';
$HTMLMessage = file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/templates/emails/' . strtolower($this->Template) . '.html');
foreach($this->EmailParam as $Key => $Value) {
$HTMLMessage = str_replace('%'. strtoupper($Key) .'%', $Value, $HTMLMessage);
}
#Example: $HTMLMessage = str_replace('%ACTIVATION%', $this->EmailParam['activation'] , $HTMLMessage);
$mail->MsgHTML($HTMLMessage);
$mail->send();
return true;
} catch (Exception $e) {
echo 'Message could not be sent.';
echo 'Mailer Error: ' . $mail->ErrorInfo;
}
}
} // End Class

152
api/app/Gateways/Stripe.php Normal file
View file

@ -0,0 +1,152 @@
<?php namespace App\Gateways;
use Model as Model;
class Stripe extends Model
{
public function __construct($key) {
parent::__construct();
\Stripe\Stripe::setApiKey($key);
}
# This is just to create a customer with a card verification
// Token is Required to create a customer into stripe.
public function CreateAccount($token, $name, $email, $description){
try {
$Customer = \Stripe\Customer::create(array(
"description" => $description,
"name" => $name,
"source" => $token,
"email" => $email
));
return ($Customer);
} catch (\Stripe\Error\Card $e){
return ($e->getMessage());
}
}
# Retrieve Subscription Information
public function RetrieveSubscription($str){
try {
$subscription = \Stripe\Subscription::retrieve($str);
return($subscription);
} catch (\Stripe\Error\Base $e) {
return ($e->getMessage());
}
}
#Create Subscription
# https://stripe.com/docs/api/subscriptions/create
// a Customer ID is required - this is retrieve from the CreateAccount method.
// a PlanID is required. This is grabbed from stripe plans (https://dashboard.stripe.com/subscriptions/products)
// My Recommendation is to use Method ($this->getPlans) to get the plans. Stripe doesn't make it easy to get the planid
public function CreateSubscription($CustID, $PlanID){
$CustomerSubscription = \Stripe\Subscription::create(array(
"customer" => $CustID,
"items" => array(
array(
"plan" => $PlanID,
"quantity" => 1,
)
)
));
return ($CustomerSubscription);
}
# Cancels Subscription
// Subscription ID is required
// https://stripe.com/docs/api/subscriptions/cancel
public function CancelSubscription($str){
try {
$subscription = \Stripe\Subscription::retrieve($str);
$subscription->cancel();
return($subscription);
} catch (\Stripe\Error\Base $e) {
return ($e->getMessage());
}
}
# Update Subscription.
# subscription code required from stripe.
// Provide with a new PlanID
// https://stripe.com/docs/api/subscriptions/update
public function UpdateSubscription($SubID, $PlanID){
$subscription = \Stripe\Subscription::retrieve($SubID);
try {
$UpdateSubscription = \Stripe\Subscription::update($SubID, [
'cancel_at_period_end' => false,
'proration_behavior' => 'always_invoice',
'items' => [
[
'id' => $subscription->items->data[0]->id,
'plan' => $PlanID,
],
]
]);
return($UpdateSubscription);
} catch (\Stripe\Error\Base $e) {
return ($e->getMessage());
}
}
#This get customer information everything about the customer from Stripe
// Just Need Customer id which looks like cust_348398439
// https://stripe.com/docs/api/customers/retrieve
public function getCustomerInfo($str){
$Return = \Stripe\Customer::retrieve($str);
return $Return;
}
#Retrieve a list of plans
public function getPlans($limit = 10){
$Return = \Stripe\Plan::all(["limit" => $limit]);
return $Return;
}
# Retrieves the Plans detail information
public function ProductInfo($planId){
$Return = \Stripe\Plan::retrieve($planId);
return $Return;
}
# Update Credit Card Info
// Coming Soon
public function UpdateCard($token, $custID){
try {
$cu = \Stripe\Customer::update(
$custID, // stored in your application
[
'source' => $token // obtained with Checkout
]
);
return $cu;
//$Message['Success'] = "Your card details have been updated!";
}
catch(\Stripe\Error\Card $e) {
return ($e->getMessage());
// $e->getJsonBody(); // show fulll error message
}
}
} // End Class

119
api/app/Helpers/Auth.php Normal file
View file

@ -0,0 +1,119 @@
<?php
class Auth {
// ─── API Authentication ───────────────────────────────────────────────────
public static function API() {
header('Access-Control-Allow-Origin: *');
// Ajax requests from inside the server pass through
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
return null;
}
// External requests require API key
if ($_SERVER['REMOTE_ADDR'] != $_SERVER['SERVER_ADDR']) {
if (SECUREAPI) {
$headers = apache_request_headers();
if ($headers['X-Authorization'] != AUTHORIZATION) die('Access Denied');
}
$ApiKey = $_REQUEST['apikey'];
$ApiUser = \Db::getRow("SELECT * FROM sp_users_api WHERE apikey = '{$ApiKey}'");
if (!$ApiUser) {
Logger::Entry([
'userid' => 0,
'trigval' => 'security.api_auth_failed',
'msgval' => 'Invalid API key presented',
'status' => 1,
]);
$Return = ['code' => '401', 'msg' => 'Unauthorized: Api Invalid'];
header('Content-Type: application/json;charset=UTF-8');
echo json_encode($Return);
exit();
}
$Return['code'] = '1';
$Return['userid'] = $ApiUser['userid'];
return $Return;
}
}
// ─── Session Login Check ──────────────────────────────────────────────────
public static function handleLogin() {
$logged = $_SESSION['login'];
if ($logged == false) {
session_destroy();
header('location: /login');
exit;
}
return $logged;
}
// ─── Load Permissions into Session ───────────────────────────────────────
// Call this once after successful login.
public static function loadPermissions(int $roleId): void {
$rows = \Db::select("
SELECT p.perm_controller, p.perm_action
FROM sp_role_perm rp
JOIN sp_permissions p ON rp.perm_id = p.perm_id
WHERE rp.role_id = ?
", [$roleId]);
$permissions = [];
foreach ($rows as $row) {
$permissions[] = $row['perm_controller'] . '.' . $row['perm_action'];
}
$_SESSION['permissions'] = $permissions;
}
// ─── Permission Checks ────────────────────────────────────────────────────
/**
* Returns true if the current user has the given permission.
* Permission format: 'controller.action' e.g. 'users.create'
*/
public static function can(string $permission): bool {
$permissions = $_SESSION['permissions'] ?? [];
return in_array($permission, $permissions);
}
/**
* Halts execution with a 403 JSON response if the user lacks the permission.
* Use in API controllers.
*/
public static function requirePermission(string $permission): void {
if (!self::can($permission)) {
Logger::Entry([
'userid' => $_SESSION['login']['userid'] ?? 0,
'trigval' => 'security.permission_denied',
'msgval' => 'Permission denied: ' . $permission,
'status' => 1,
]);
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
}
/**
* Returns all permissions for the current user as an array.
*/
public static function getPermissions(): array {
return $_SESSION['permissions'] ?? [];
}
/**
* Returns a JS-safe array of the current user's permissions.
* Use in header partial to inject into window.AppUser.
*/
public static function getPermissionsJson(): string {
return json_encode(self::getPermissions());
}
}

366
api/app/Helpers/DB.php Normal file
View file

@ -0,0 +1,366 @@
<?php
class Db
{
private static $_connections = [];
private static $_activeConnection = null;
protected static $_fetchMode = \PDO::FETCH_ASSOC;
protected static $_driverOptions = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8; SET time_zone = 'America/New_York'"
];
/**
* Set connection information
*
* @example Db::setConnectionInfo('mysql', 'dbname', 'username', 'password', 'hostname', 'connectionName');
*/
public static function setConnectionInfo(
$driver,
$dbname,
$username,
$password,
$hostname = 'localhost',
$connectionName = 'default'
) {
self::$_connections[$connectionName] = [
'driver' => $driver,
'connectionString' => "{$driver}:dbname={$dbname};host={$hostname}",
'username' => $username,
'password' => $password,
];
if (self::$_activeConnection === null) {
self::$_activeConnection = $connectionName;
}
}
/**
* Switch to a different connection
*/
public static function useConnection($connectionName)
{
if (!isset(self::$_connections[$connectionName])) {
throw new \Exception("Connection '{$connectionName}' does not exist.");
}
self::$_activeConnection = $connectionName;
}
/**
* Get the current PDO object
*/
public static function getPDOObject()
{
return self::_getConnection();
}
/**
* Execute a statement and returns the first row
*/
public static function getRow($sql, $params = [])
{
$statement = self::_query($sql, $params);
return $statement->fetch(self::$_fetchMode);
}
/**
* Execute a statement and returns all rows
*/
public static function select($sql, $params = [])
{
$statement = self::_query($sql, $params);
return $statement->fetchAll(self::$_fetchMode);
}
/**
* Insert a new row into the database
*/
public static function insert($table, $data)
{
$pdo = self::_getConnection();
ksort($data);
$fieldNames = implode('`, `', array_keys($data));
$fieldValues = ':' . implode(', :', array_keys($data));
try {
$sth = $pdo->prepare("INSERT INTO $table (`$fieldNames`) VALUES ($fieldValues)");
foreach ($data as $key => $value) {
$sth->bindValue(":$key", $value);
}
$sth->execute();
return json_encode([
'Code' => 1,
'Message' => 'Created',
'ID' => $pdo->lastInsertId()
]);
} catch (\PDOException $e) {
return json_encode([
'Code' => 0,
'Message' => 'Error Creating Entry: ' . $e->getMessage()
]);
}
}
/**
* Update existing rows in the database
*/
public static function update($table, $data, $where, $whereParams = [])
{
$pdo = self::_getConnection();
ksort($data);
$usesPositionalWhere = str_contains($where, '?');
if ($usesPositionalWhere) {
$fieldDetails = implode(', ', array_map(function($key) {
return "`$key` = ?";
}, array_keys($data)));
} else {
$fieldDetails = implode(', ', array_map(function($key) {
return "`$key` = :set_$key";
}, array_keys($data)));
}
try {
$sth = $pdo->prepare("UPDATE $table SET $fieldDetails WHERE $where");
if ($usesPositionalWhere) {
// Single positional style: SET values first, WHERE values after.
$params = array_values($data);
if (!empty($whereParams)) {
$params = array_merge($params, array_values($whereParams));
}
$sth->execute($params);
} else {
// Single named style for SET; allow named WHERE params if supplied.
$params = [];
foreach ($data as $key => $value) {
$params["set_$key"] = $value;
}
foreach ($whereParams as $key => $value) {
$params[ltrim((string)$key, ':')] = $value;
}
$sth->execute($params);
}
$count = $sth->rowCount();
return json_encode([
'Code' => ($count > 0) ? 1 : 0,
'Rows' => $count,
'Message' => ($count > 0) ? 'Updated' : 'No Records Updated'
]);
} catch (\PDOException $e) {
return json_encode([
'Code' => $e->errorInfo[1],
'Message' => 'Error Updating Database: ' . $e->getMessage()
]);
}
}
/**
* Execute a statement and returns number of affected rows
*/
public static function execute($sql, $params = [])
{
$statement = self::_query($sql, $params);
return $statement->rowCount();
}
/**
* Execute a statement and returns a single value
*/
public static function getValue($sql, $params = [])
{
$statement = self::_query($sql, $params);
return $statement->fetchColumn(0);
}
/**
* Set PDO fetch mode
*/
public static function setFetchMode($fetchMode)
{
self::$_fetchMode = $fetchMode;
}
/**
* Begin a transaction
*/
public static function beginTransaction()
{
self::_getConnection()->beginTransaction();
}
/**
* Commit a transaction
*/
public static function commitTransaction()
{
self::_getConnection()->commit();
}
/**
* Rollback a transaction
*/
public static function rollbackTransaction()
{
self::_getConnection()->rollBack();
}
/**
* Backward-compatible aliases used across controllers.
*/
public static function commit()
{
self::commitTransaction();
}
public static function rollback()
{
self::rollbackTransaction();
}
/**
* Set PDO driver options
*/
public static function setDriverOptions(array $options)
{
self::$_driverOptions = $options;
}
/**
* Get or create the PDO connection
*/
private static function _getConnection()
{
$connectionInfo = self::$_connections[self::$_activeConnection];
if (!isset($connectionInfo['pdo'])) {
$connectionInfo['pdo'] = new \PDO(
$connectionInfo['connectionString'],
$connectionInfo['username'],
$connectionInfo['password'],
self::$_driverOptions
);
self::$_connections[self::$_activeConnection] = $connectionInfo;
}
return $connectionInfo['pdo'];
}
/**
* Prepare and execute a PDO statement
*/
private static function _query($sql, $params = [])
{
$pdo = self::_getConnection();
$statement = $pdo->prepare($sql);
if (!$statement) {
$errorInfo = $pdo->errorInfo();
throw new \PDOException("Database error [{$errorInfo[0]}]: {$errorInfo[2]}, driver error code is $errorInfo[1]");
}
if (!$statement->execute($params) || $statement->errorCode() != '00000') {
$errorInfo = $statement->errorInfo();
throw new \PDOException("Database error [{$errorInfo[0]}]: {$errorInfo[2]}, driver error code is $errorInfo[1]");
}
return $statement;
}
/**
* Delete rows from the database
*/
public static function delete($table, $where)
{
$pdo = self::_getConnection();
// Build the WHERE clause dynamically
$whereConditions = implode(' AND ', array_map(function($key) {
return "`$key` = :$key";
}, array_keys($where)));
try {
$sth = $pdo->prepare("DELETE FROM $table WHERE $whereConditions");
// Bind the parameters from the where array
foreach ($where as $key => $value) {
$sth->bindValue(":$key", $value);
}
$sth->execute();
$count = $sth->rowCount();
return json_encode([
'Code' => ($count > 0) ? 1 : 0,
'Rows' => $count,
'Message' => ($count > 0) ? 'Deleted' : 'No Records Deleted'
]);
} catch (\PDOException $e) {
return json_encode([
'Code' => $e->errorInfo[1],
'Message' => 'Error Deleting From Database: ' . $e->getMessage()
]);
}
}
public static function batchInsert($table, array $data)
{
if (empty($data)) {
return json_encode([
'Code' => 0,
'Message' => 'No data provided for insertion'
]);
}
$pdo = self::_getConnection();
// Assume all rows have the same structure as the first row
$firstRow = reset($data);
$columns = array_keys($firstRow);
$columnString = '`' . implode('`, `', $columns) . '`';
// Create placeholders for each row
$rowPlaceholder = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')';
$valuePlaceholders = implode(', ', array_fill(0, count($data), $rowPlaceholder));
$sql = "INSERT INTO $table ($columnString) VALUES $valuePlaceholders";
try {
$stmt = $pdo->prepare($sql);
// Flatten the data array and bind values
$values = [];
foreach ($data as $row) {
foreach ($columns as $column) {
$values[] = $row[$column];
}
}
$stmt->execute($values);
$insertedCount = $stmt->rowCount();
return json_encode([
'Code' => 1,
'Message' => 'Batch insert successful',
'InsertedRows' => $insertedCount
]);
} catch (\PDOException $e) {
return json_encode([
'Code' => 0,
'Message' => 'Error performing batch insert: ' . $e->getMessage()
]);
}
}
}

22
api/app/Helpers/Debug.php Normal file
View file

@ -0,0 +1,22 @@
<?php
class Debug {
public static function print_array($f, $kill=false) {
echo '<pre>';
print_r($f);
echo '</pre>';
if($kill){
die('-- Debugging --');
}
}
public static function sendlog($string){
error_log($string, 0);
}
} // End Class

View file

@ -0,0 +1,84 @@
<?php
class Format {
public static function Date($d, $format = 'm-d-Y') {
return date($format , strtotime($d));
}
public static function Phone($phoneNumber) {
$phoneNumber = preg_replace('/[^0-9]/','',$phoneNumber);
if(strlen($phoneNumber) > 10) {
$countryCode = substr($phoneNumber, 0, strlen($phoneNumber)-10);
$areaCode = substr($phoneNumber, -10, 3);
$nextThree = substr($phoneNumber, -7, 3);
$lastFour = substr($phoneNumber, -4, 4);
$phoneNumber = '+'.$countryCode.' ('.$areaCode.') '.$nextThree.'-'.$lastFour;
}
else if(strlen($phoneNumber) == 10) {
$areaCode = substr($phoneNumber, 0, 3);
$nextThree = substr($phoneNumber, 3, 3);
$lastFour = substr($phoneNumber, 6, 4);
$phoneNumber = '('.$areaCode.') '.$nextThree.'-'.$lastFour;
}
else if(strlen($phoneNumber) == 7) {
$nextThree = substr($phoneNumber, 0, 3);
$lastFour = substr($phoneNumber, 3, 4);
$phoneNumber = $nextThree.'-'.$lastFour;
}
return $phoneNumber;
}
public static function formatCurrency($amount='', $thousands='') {
// Convert to float and divide by 100
$formattedAmount = number_format($amount / 100, 2, '.', $thousands);
return $formattedAmount;
}
/**
* Convert Equifax date strings to MM/DD/YYYY display format.
* Handles: YYYYMMDD, YYYY-MM-DD, MM/YYYY, MM/DD/YYYY passthrough.
* Returns null on empty/null input.
*/
public static function eqFormatDate($date) {
if (empty($date)) return null;
$date = trim((string)$date);
// YYYYMMDD (8 digits)
if (preg_match('/^\d{8}$/', $date)) {
$d = \DateTime::createFromFormat('Ymd', $date);
return $d ? $d->format('m/d/Y') : null;
}
// YYYY-MM-DD
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$d = \DateTime::createFromFormat('Y-m-d', $date);
return $d ? $d->format('m/d/Y') : null;
}
// MM/YYYY — partial date, use first of month
if (preg_match('/^\d{2}\/\d{4}$/', $date)) {
$d = \DateTime::createFromFormat('m/Y', $date);
return $d ? $d->format('m/01/Y') : null;
}
// MM/DD/YYYY — already correct format, passthrough
if (preg_match('/^\d{2}\/\d{2}\/\d{4}$/', $date)) {
return $date;
}
// Fallback: try strtotime
$ts = strtotime($date);
return $ts ? date('m/d/Y', $ts) : null;
}
} // End Class

View file

@ -0,0 +1,287 @@
<?php
class Functions {
public static function getRoles($f='') {
foreach($f as $key => $fQuery) {
$SQLWhere .= "AND {$key} like '%{$fQuery}%' ";
}
$Return = \Db::getResult("select * from sp_roles WHERE 1 {$SQLWhere}");
return $Return;
}
public static function getUserInfo($userid=''){
if(!$userid) die('UserID Required...');
$Return = \Db::getRow("SELECT * FROM sp_users a JOIN sp_roles b ON a.role_id=b.role_id WHERE userid='{$userid}'");
return $Return;
}
public static function getStates(){
}
public static function getCountries(){
}
public static function isSysAdmin() {
// \Session::get('login');
}
/**
* Check if the user exist in the database by email.
* @param $f
* @return array|false
*/
public static function CheckUserExist($f){
$j = \Db::getRow("select * from sp_users where email ='{$f}'");
return (($j) ? $j : false);
}
/**
* Get either a Gravatar URL or complete image tag for a specified email address.
*
* @param string $email The email address
* @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ]
* @param string $d Default imageset to use [ 404 | mp | identicon | monsterid | wavatar ]
* @param string $r Maximum rating (inclusive) [ g | pg | r | x ]
* @param boole $img True to return a complete IMG tag False for just the URL
* @param array $atts Optional, additional key/value attributes to include in the IMG tag
* @return String containing either just a URL or a complete image tag
* @source https://gravatar.com/site/implement/images/php/
*/
public static function getAvatar($email, $s = 80, $d = 'mp', $r = 'g', $img = false, $atts = array()) {
$url = 'https://www.gravatar.com/avatar/';
$url .= md5( strtolower( trim( $email ) ) );
$url .= "?s=$s&d=$d&r=$r";
if ( $img ) {
$url = '<img src="' . $url . '"';
foreach ( $atts as $key => $val )
$url .= ' ' . $key . '="' . $val . '"';
$url .= ' />';
}
return $url;
}
/** Create Encryption for security in the application
*
* @param string $st The string that needs to be encrypted
* Usage: echo \Functions::Encrypt("Carlos");
*
*/
public static function Encrypt($st){
$encrypted = openssl_encrypt($st, 'AES-128-CTR', HASH_PASSWORD_KEY, OPENSSL_RAW_DATA, '1234567890123456');
return base64_encode($encrypted);
}
/** Create Encryption for security in the application
*
* @param string $st The string that needs to be encrypted
* Usage: echo \Functions::Decrypt("a/pxWERALz2YMd4l32U3ew==");
*
*/
public static function Decrypt($st){
$decrypted = openssl_decrypt(base64_decode($st), 'AES-128-CTR', HASH_PASSWORD_KEY, OPENSSL_RAW_DATA, '1234567890123456');
return $decrypted;
}
public static function DefualtRole(){
Return \Db::getRow("SELECT role_id, role_name FROM sp_roles WHERE defaultrole='1'");
}
public static function encryptData($data, $key) {
$ivLength = openssl_cipher_iv_length($cipher = 'AES-256-CBC');
$iv = openssl_random_pseudo_bytes($ivLength);
$encrypted = openssl_encrypt($data, $cipher, $key, OPENSSL_RAW_DATA, $iv);
// Convert to hex to ensure binary safety, then to base64 to make it URL-friendly
$encryptedBase64 = base64_encode($iv . $encrypted);
// Replace URL-unfriendly characters from base64 encoding
$urlSafeEncrypted = strtr($encryptedBase64, '+/', '-_');
// Optionally remove '=' if present
$urlSafeEncrypted = rtrim($urlSafeEncrypted, '=');
return $urlSafeEncrypted;
}
public static function decryptData($urlSafeEncrypted, $key) {
$ivLength = openssl_cipher_iv_length($cipher = 'AES-256-CBC');
// Reverse the URL-safe transformations
$base64Encrypted = strtr($urlSafeEncrypted, '-_', '+/');
// Decode from base64 to binary
$binaryData = base64_decode($base64Encrypted);
$iv = substr($binaryData, 0, $ivLength);
$encrypted = substr($binaryData, $ivLength);
$decrypted = openssl_decrypt($encrypted, $cipher, $key, OPENSSL_RAW_DATA, $iv);
return $decrypted;
}
/*// Usage
$key = 'your-256-bit-secret-key'; // Make sure to use a secure key
$originalData = "Your secret data";
$encryptedData = encryptData($originalData, $key);
echo "Encrypted: " . $encryptedData . "\n";
$decryptedData = decryptData($encryptedData, $key);
echo "Decrypted: " . $decryptedData . "\n";
*/
/**
* Resolve an org from a tokenized slug (e.g. "acme-corp.AbcToken").
* Decrypts the token, validates it, and returns the active org row.
* Calls die() with an error message on any failure.
*
* @param string $slug
* @return array
*/
public static function resolveOrg($slug) {
if (!$slug || strpos($slug, '.') === false) die('Invalid link');
[, $token] = explode('.', $slug, 2);
$orgid = self::decryptData($token, HASH_PASSWORD_KEY);
if (!$orgid || !is_numeric($orgid)) die('Invalid link');
$org = \Db::getRow("SELECT * FROM sp_orgs WHERE orgid = ? AND active = 1", [$orgid]);
if (!$org) die('Organization not found');
return $org;
}
/**
* Resolve a user from a tokenized slug (e.g. "john-doe.AbcToken").
* Decrypts the token, validates it, and returns the user row (with role).
* Calls die() with an error message on any failure.
*
* @param string $slug
* @return array
*/
public static function resolveUser($slug) {
if (!$slug || strpos($slug, '.') === false) die('Invalid link');
[, $token] = explode('.', $slug, 2);
$userid = self::decryptData($token, HASH_PASSWORD_KEY);
if (!$userid || !is_numeric($userid)) die('Invalid link');
$user = \Db::getRow("SELECT u.*, r.role_name FROM sp_users u LEFT JOIN sp_roles r ON u.role_id = r.role_id WHERE u.userid = ?", [$userid]);
if (!$user) die('User not found');
return $user;
}
// This gets the system default organization.
// Ideally for single organization applications with users going straight into the org or for api calls that have no business name assigned to the field.
public static function DefaultOrg(){
Return \Db::getRow("SELECT orgid, catid, name FROM sp_orgs WHERE defaultorg='1'");
}
public static function randomPassword() {
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
$pass = array(); //remember to declare $pass as an array
$alphaLength = strlen($alphabet) - 1; //put the length -1 in cache
for ($i = 0; $i < 8; $i++) {
$n = rand(0, $alphaLength);
$pass[] = $alphabet[$n];
}
return implode($pass); //turn the array into a string
}
public static function ip_in_range($ip, $range) {
if (strpos($range, '/') == false)
$range .= '/32';
// $range is in IP/CIDR format eg 127.0.0.1/24
list($range, $netmask) = explode('/', $range, 2);
$range_decimal = ip2long($range);
$ip_decimal = ip2long($ip);
$wildcard_decimal = pow(2, (32 - $netmask)) - 1;
$netmask_decimal = ~ $wildcard_decimal;
return (($ip_decimal & $netmask_decimal) == ($range_decimal & $netmask_decimal));
}
public static function CheckCloudFlare($ip) {
$cf_ips = array(
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22'
);
$is_cf_ip = false;
foreach ($cf_ips as $cf_ip) {
if (self::ip_in_range($ip, $cf_ip)) {
$is_cf_ip = true;
break;
}
} return $is_cf_ip;
}
public static function in_array_all($needles, $haystack) {
return empty(array_diff($needles, $haystack));
}
public static function hasOrgAdminAccess($userArray) {
foreach ($userArray as $org) {
if ($org['OrgAdmin'] == 1) {
return true;
}
}
return false;
}
static function getDomainIP($domain) {
// Validate domain format
if (!filter_var($domain, FILTER_VALIDATE_DOMAIN)) {
return "Invalid domain format";
}
$result = [];
try {
// Method 1: Get single IP using gethostbyname()
$ip = gethostbyname($domain);
if ($ip !== $domain) {
$result['primary_ip'] = $ip;
}
// Method 2: Get all DNS A records
$dns_records = dns_get_record($domain, DNS_A);
if (!empty($dns_records)) {
$result['dns_records'] = array_column($dns_records, 'ip');
}
// Method 3: Get all IPv4 addresses
if (checkdnsrr($domain, 'A')) {
$ip_array = gethostbynamel($domain);
if ($ip_array) {
$result['all_ipv4'] = $ip_array;
}
}
if (empty($result)) {
return "Could not resolve domain";
}
return $result;
} catch (Exception $e) {
return "Error: " . $e->getMessage();
}
}
} // End Class

View file

@ -0,0 +1,16 @@
<?php
class GlobalConst {
public static function getCountries($s=""){
$SQLReturn = \DB::select("SELECT * FROM snpi_countries WHERE 1");
return $SQLReturn;
}
public static function getStates($s=""){
$SQLReturn = \DB::select("SELECT * FROM snpi_states WHERE 1");
return $SQLReturn;
}
} // End Class

67
api/app/Helpers/Hash.php Normal file
View file

@ -0,0 +1,67 @@
<?php
class Hash
{
/**
*
* @param string $algo The algorithm (md5, sha1, whirlpool, etc)
* @param string $data The data to encode
* @param string $salt The salt (This should be the same throughout the system probably)
* @return string The hashed/salted data
*/
public static function create($algo, $data, $salt)
{
$context = hash_init($algo, HASH_HMAC, $salt);
hash_update($context, $data);
return hash_final($context);
}
public static function createApi(){
$key = implode('-', str_split(substr(strtolower(md5(microtime().rand(1000, 9999))), 0, 30), 6));
return $key;
}
public static function formData(){
$key = implode('-', str_split(substr(strtolower(md5(microtime().rand(1000, 9999))), 0, 15), 5));
return $key;
}
/**
* Normalize a date-of-birth string to YYYYMMDD for consistent hashing.
* Handles: YYYY-MM-DD (from HTML date input), MM/DD/YYYY, MMDDYYYY, YYYYMMDD.
*/
public static function normalizeDob($dob) {
if (empty($dob)) return '';
$dob = trim((string)$dob);
// Already YYYYMMDD
if (preg_match('/^\d{8}$/', $dob)) return $dob;
// YYYY-MM-DD (HTML date input)
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob)) {
return str_replace('-', '', $dob);
}
// MM/DD/YYYY
if (preg_match('/^\d{2}\/\d{2}\/\d{4}$/', $dob)) {
$d = \DateTime::createFromFormat('m/d/Y', $dob);
return $d ? $d->format('Ymd') : $dob;
}
// Fallback
$ts = strtotime($dob);
return $ts ? date('Ymd', $ts) : $dob;
}
/**
* Create a stable consumer profile ID from DOB + SSN.
* Returns first 32 chars of HMAC-SHA256.
*/
public static function createConsumerProfileId($dob, $ssn, $salt) {
$normalized = self::normalizeDob($dob) . preg_replace('/[^0-9]/', '', $ssn);
return substr(hash_hmac('sha256', $normalized, $salt), 0, 32);
}
}

View file

@ -0,0 +1,72 @@
<?php
class Logger {
/*
* Log Information to Database
* Auto-populates: host, method, useragent, device_type, browser, os, ipaddress, location.
* Callers may pass controller/action explicitly.
*/
public static function Entry($logData) {
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$parsed = self::parseUserAgent($ua);
$logData['location'] = $logData['location'] ?? 'na';
$logData['ipaddress'] = $logData['ipaddress'] ?: ($_SERVER['REMOTE_ADDR'] ?? '');
$logData['host'] = $_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_HOST'] ?? '';
$logData['method'] = $logData['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? '');
$logData['useragent'] = $logData['useragent'] ?? substr($ua, 0, 512);
$logData['device_type'] = $logData['device_type'] ?? $parsed['device_type'];
$logData['browser'] = $logData['browser'] ?? $parsed['browser'];
$logData['os'] = $logData['os'] ?? $parsed['os'];
// controller/action passed explicitly by caller
return \Db::insert('sp_activitylog', $logData);
}
/*
* Parse a User-Agent string into device_type, browser, os.
*/
private static function parseUserAgent(string $ua): array {
$u = strtolower($ua);
// Device type
if (preg_match('/tablet|ipad|kindle|playbook|silk|(android(?!.*mobile))/i', $ua)) {
$device_type = 'tablet';
} elseif (preg_match('/mobile|iphone|ipod|android.*mobile|blackberry|opera mini|iemobile/i', $ua)) {
$device_type = 'mobile';
} else {
$device_type = 'desktop';
}
// Browser (order matters — Edge/Opera before Chrome/Safari)
if (str_contains($u, 'edg/') || str_contains($u, 'edge/')) $browser = 'Edge';
elseif (str_contains($u, 'opr/') || str_contains($u, 'opera')) $browser = 'Opera';
elseif (str_contains($u, 'chrome') && !str_contains($u, 'chromium')) $browser = 'Chrome';
elseif (str_contains($u, 'firefox') || str_contains($u, 'fxios')) $browser = 'Firefox';
elseif (str_contains($u, 'safari') && !str_contains($u, 'chrome')) $browser = 'Safari';
elseif (str_contains($u, 'msie') || str_contains($u, 'trident')) $browser = 'IE';
else $browser = 'Other';
// OS
if (str_contains($u, 'iphone') || str_contains($u, 'ipod')) $os = 'iOS';
elseif (str_contains($u, 'ipad')) $os = 'iPadOS';
elseif (str_contains($u, 'android')) $os = 'Android';
elseif (str_contains($u, 'windows')) $os = 'Windows';
elseif (str_contains($u, 'macintosh') || str_contains($u, 'mac os x')) $os = 'macOS';
elseif (str_contains($u, 'cros')) $os = 'ChromeOS';
elseif (str_contains($u, 'linux')) $os = 'Linux';
else $os = 'Other';
return compact('device_type', 'browser', 'os');
}
/*
* Log Information to a File
*/
public static function LogtoFile($string){
error_log($string, 0);
}
} // End Class

146
api/app/Helpers/Menu.php Normal file
View file

@ -0,0 +1,146 @@
<?php
class Menu {
/**
* Resolve a menu item URL by preferred labels within a menu slug.
* Useful for linking static template buttons to Menu Builder-managed URLs.
*/
public static function getItemUrl(string $slug, array $labels, string $fallback = '#'): string {
if (empty($labels)) return $fallback;
$rows = Db::select("
SELECT mi.label, mi.url
FROM sp_menu_items mi
JOIN sp_menus m ON m.menu_id = mi.menu_id
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
AND mi.type = 'link' AND mi.url IS NOT NULL
", [$slug]);
if (!$rows) return $fallback;
$urlByLabel = [];
foreach ($rows as $row) {
$key = strtolower(trim((string)$row['label']));
$urlByLabel[$key] = (string)$row['url'];
}
foreach ($labels as $label) {
$key = strtolower(trim((string)$label));
if (isset($urlByLabel[$key]) && trim($urlByLabel[$key]) !== '') {
return $urlByLabel[$key];
}
}
return $fallback;
}
/**
* Returns the full nested menu tree for the given slug,
* filtered by the current user's permissions (loaded from their role).
*/
public static function getTree(string $slug): array {
$rows = Db::select("
SELECT mi.*, p.perm_controller, p.perm_action
FROM sp_menu_items mi
JOIN sp_menus m ON m.menu_id = mi.menu_id
LEFT JOIN sp_permissions p ON p.perm_id = mi.perm_id
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
ORDER BY mi.sort_order ASC
", [$slug]);
if (!$rows) return [];
$isSysAdmin = !empty($_SESSION['login']['sysadmin']);
if (!$isSysAdmin) {
$userPerms = $_SESSION['permissions'] ?? [];
$rows = array_values(array_filter($rows, function($item) use ($userPerms) {
if (!$item['perm_id']) return true;
$key = $item['perm_controller'] . '.' . $item['perm_action'];
return in_array($key, $userPerms);
}));
}
$currentUrl = strtok($_SERVER['REQUEST_URI'], '?');
return self::buildTree($rows, $currentUrl);
}
/**
* Resolves dynamic URL tokens for the logged-in user.
*
* Supported tokens (use in the menu builder URL field):
* %ORG_TOKEN% encrypted slug of the user's company /company/%ORG_TOKEN%
* %USER_TOKEN% encrypted slug of the logged-in user /account/%USER_TOKEN%/edit
*/
private static function resolveUrlTokens(string $url): string {
if (strpos($url, '%') === false) return $url;
if (strpos($url, '%ORG_TOKEN%') !== false) {
$orgId = $_SESSION['login']['orgid'] ?? 0;
if ($orgId) {
$token = Functions::encryptData((string)$orgId, HASH_PASSWORD_KEY);
$url = str_replace('%ORG_TOKEN%', 'org.' . $token, $url);
}
}
if (strpos($url, '%USER_TOKEN%') !== false) {
$userId = $_SESSION['login']['userid'] ?? 0;
if ($userId) {
$token = Functions::encryptData((string)$userId, HASH_PASSWORD_KEY);
$url = str_replace('%USER_TOKEN%', 'user.' . $token, $url);
}
}
return $url;
}
private static function buildTree(array $rows, string $currentUrl, int $parentId = 0): array {
$branch = [];
foreach ($rows as $row) {
if ((int)$row['parent_id'] === $parentId) {
$row['children'] = self::buildTree($rows, $currentUrl, (int)$row['item_id']);
$rawUrl = $row['url'] ?? '';
$itemUrl = rtrim(self::resolveUrlTokens($rawUrl), '/');
$row['url'] = $itemUrl; // write resolved URL back for the renderer
$curUrl = rtrim($currentUrl, '/');
// URLs containing dynamic tokens (e.g. %ORG_TOKEN%) are encrypted with a
// random IV, so the resolved URL differs on every request and can never
// equal the token already present in the browser's address bar.
// Instead, match on the static path segment that precedes the token.
if (strpos($rawUrl, '%') !== false) {
$staticPrefix = rtrim(preg_replace('/%[^%]+%.*$/', '', $rawUrl), '/');
$row['is_active'] = $staticPrefix !== '' && (
$curUrl === $staticPrefix ||
str_starts_with($curUrl, $staticPrefix . '/')
);
} else {
$row['is_active'] = $itemUrl !== '' && $curUrl === $itemUrl;
}
if (!empty($row['children'])) {
$row['is_open'] = $row['is_active'] || self::hasActiveChild($row['children']);
} elseif ($row['match_prefix'] && $itemUrl !== '') {
$row['is_active'] = str_starts_with($currentUrl, $itemUrl);
$row['is_open'] = $row['is_active'];
} else {
$row['is_open'] = false;
}
// Skip section headers with no visible children
if ($row['type'] === 'header' && empty($row['children'])) continue;
$branch[] = $row;
}
}
return $branch;
}
private static function hasActiveChild(array $children): bool {
foreach ($children as $child) {
if ($child['is_active'] || $child['is_open']) return true;
}
return false;
}
}

View file

@ -0,0 +1,52 @@
<?php
# Deprecate this
class Numbers {
public static function ValidPhone($str) {
if(preg_match('/^(\d{1,4}[ -]?)?(\d{6,10}|(\d{1,5}[ -]?\d{1,5}))(?![\d-])/', $str)) {
return 1;
} else {
return 0;
}
}
public static function onlyNumbers($c){
return preg_replace('/\D/', '', $c);
}
public static function formatDate($d, $format = 'm-d-Y') {
return date($format , strtotime($d));
}
public static function formatPhoneNumber($phoneNumber) {
$phoneNumber = preg_replace('/[^0-9]/','',$phoneNumber);
if(strlen($phoneNumber) > 10) {
$countryCode = substr($phoneNumber, 0, strlen($phoneNumber)-10);
$areaCode = substr($phoneNumber, -10, 3);
$nextThree = substr($phoneNumber, -7, 3);
$lastFour = substr($phoneNumber, -4, 4);
$phoneNumber = '+'.$countryCode.' ('.$areaCode.') '.$nextThree.'-'.$lastFour;
}
else if(strlen($phoneNumber) == 10) {
$areaCode = substr($phoneNumber, 0, 3);
$nextThree = substr($phoneNumber, 3, 3);
$lastFour = substr($phoneNumber, 6, 4);
$phoneNumber = '('.$areaCode.') '.$nextThree.'-'.$lastFour;
}
else if(strlen($phoneNumber) == 7) {
$nextThree = substr($phoneNumber, 0, 3);
$lastFour = substr($phoneNumber, 3, 4);
$phoneNumber = $nextThree.'-'.$lastFour;
}
return $phoneNumber;
}
} // End Class

View file

@ -0,0 +1,177 @@
<?php
class Pagination extends Model
{
/**
* set the number of items per page.
*
* @var numeric
*/
private $_perPage;
/**
* set get parameter for fetching the page number
*
* @var string
*/
private $_instance;
/**
* sets the page number.
*
* @var numeric
*/
private $_page;
/**
* set the limit for the data source
*
* @var string
*/
private $_limit;
/**
* set the total number of records/items.
*
* @var numeric
*/
private $_totalRows = 0;
/**
* __construct
*
* pass values when class is istantiated
*
* @param numeric $_perPage sets the number of iteems per page
* @param numeric $_instance sets the instance for the GET parameter
*/
public function __construct($perPage,$instance){
$this->_instance = $instance;
$this->_perPage = $perPage;
$this->set_instance();
}
/**
* get_start
*
* creates the starting point for limiting the dataset
* @return numeric
*/
public function get_start(){
return ($this->_page * $this->_perPage) - $this->_perPage;
}
/**
* set_instance
*
* sets the instance parameter, if numeric value is 0 then set to 1
*
* @var numeric
*/
private function set_instance(){
$this->_page = (int) (!isset($_GET[$this->_instance]) ? 1 : $_GET[$this->_instance]);
$this->_page = ($this->_page == 0 ? 1 : $this->_page);
}
/**
* set_total
*
* collect a numberic value and assigns it to the totalRows
*
* @var numeric
*/
public function set_total($_totalRows){
$this->_totalRows = $_totalRows;
}
/**
* get_limit
*
* returns the limit for the data source, calling the get_start method and passing in the number of items perp page
*
* @return string
*/
public function get_limit(){
return "LIMIT ".$this->get_start().",$this->_perPage";
}
/**
* page_links
*
* create the html links for navigating through the dataset
*
* @var sting $path optionally set the path for the link
* @var sting $ext optionally pass in extra parameters to the GET
* @return string returns the html menu
*/
public function page_links($path='?',$ext=null)
{
$adjacents = "2";
$prev = $this->_page - 1;
$next = $this->_page + 1;
$lastpage = ceil($this->_totalRows/$this->_perPage);
$lpm1 = $lastpage - 1;
$pagination = "";
if($lastpage > 1)
{
$pagination .= "<ul class='pagination'>";
if ($this->_page > 1)
$pagination.= "<li><a href='".$path."$this->_instance=$prev"."$ext'>Previous</a></li>";
else
$pagination.= "<span class='disabled'>Previous</span>";
if ($lastpage < 7 + ($adjacents * 2))
{
for ($counter = 1; $counter <= $lastpage; $counter++)
{
if ($counter == $this->_page)
$pagination.= "<li><span class='current'>$counter</span></li>";
else
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
}
}
elseif($lastpage > 5 + ($adjacents * 2))
{
if($this->_page < 1 + ($adjacents * 2))
{
for ($counter = 1; $counter < 4 + ($adjacents * 2); $counter++)
{
if ($counter == $this->_page)
$pagination.= "<li><span class='current'>$counter</span></li>";
else
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
}
$pagination.= "...";
$pagination.= "<li><a href='".$path."$this->_instance=$lpm1"."$ext'>$lpm1</a></li>";
$pagination.= "<li><a href='".$path."$this->_instance=$lastpage"."$ext'>$lastpage</a></li>";
}
elseif($lastpage - ($adjacents * 2) > $this->_page && $this->_page > ($adjacents * 2))
{
$pagination.= "<li><a href='".$path."$this->_instance=1"."$ext'>1</a></li>";
$pagination.= "<li><a href='".$path."$this->_instance=2"."$ext'>2</a></li>";
$pagination.= "...";
for ($counter = $this->_page - $adjacents; $counter <= $this->_page + $adjacents; $counter++)
{
if ($counter == $this->_page)
$pagination.= "<span class='current'>$counter</span>";
else
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
}
$pagination.= "..";
$pagination.= "<li><a href='".$path."$this->_instance=$lpm1"."$ext'>$lpm1</a></li>";
$pagination.= "<li><a href='".$path."$this->_instance=$lastpage"."$ext'>$lastpage</a></li>";
}
else
{
$pagination.= "<li><a href='".$path."$this->_instance=1"."$ext'>1</a></li>";
$pagination.= "<li><a href='".$path."$this->_instance=2"."$ext'>2</a></li>";
$pagination.= "..";
for ($counter = $lastpage - (2 + ($adjacents * 2)); $counter <= $lastpage; $counter++)
{
if ($counter == $this->_page)
$pagination.= "<span class='current'>$counter</span>";
else
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
}
}
}
if ($this->_page < $counter - 1)
$pagination.= "<li><a href='".$path."$this->_instance=$next"."$ext'>Next</a></li>";
else
$pagination.= "<li><span class='disabled'>Next</span></li>";
$pagination.= "</ul>\n";
}
return $pagination;
}
}

View file

@ -0,0 +1,353 @@
<?php
/**
* PluginManager WordPress-style plugin engine for SeedProject.
*
* Plugins live in plugins/{slug}/ and are integrated via symlinks into
* public/controllers/, public/views/plugins/, and public/assets/plugins/.
* No core framework files need modification beyond a single boot() call.
*/
class PluginManager {
private static string $pluginsDir;
private static string $controllersDir;
private static string $viewsDir;
private static string $assetsDir;
private static function init(): void {
static $initialized = false;
if ($initialized) return;
$base = dirname(__DIR__, 2); // project root
self::$pluginsDir = $base . '/plugins';
self::$controllersDir = $base . '/public/controllers';
self::$viewsDir = $base . '/public/views/plugins';
self::$assetsDir = $base . '/public/assets/plugins';
$initialized = true;
}
// ─── Boot (called once per request from Router::Routing) ─────────────────
/**
* Register AltoRouter routes for all enabled plugins.
* Called from Router::Routing() before $router->match().
*/
public static function boot(AltoRouter $router): void {
self::init();
$plugins = Db::select(
"SELECT slug FROM sp_plugins WHERE enabled = 1",
[]
);
if (!$plugins) return;
foreach ($plugins as $row) {
$slug = $row['slug'];
$manifestPath = self::$pluginsDir . '/' . $slug . '/plugin.php';
if (!file_exists($manifestPath)) continue;
$manifest = require $manifestPath;
foreach ($manifest['routes'] ?? [] as $rule) {
// Each rule: [method, pattern, target, name]
if (count($rule) >= 3) {
$router->map($rule[0], $rule[1], $rule[2], $rule[3] ?? null);
}
}
}
}
// ─── Install ──────────────────────────────────────────────────────────────
/**
* Install a plugin: create symlinks, run install.php, upsert DB row (enabled=0).
*/
public static function install(string $slug): array {
self::init();
$slug = self::sanitizeSlug($slug);
if (!$slug) return ['success' => false, 'error' => 'Invalid slug'];
$pluginDir = self::$pluginsDir . '/' . $slug;
if (!is_dir($pluginDir)) {
return ['success' => false, 'error' => "Plugin directory not found: {$slug}"];
}
$manifestPath = $pluginDir . '/plugin.php';
if (!file_exists($manifestPath)) {
return ['success' => false, 'error' => "plugin.php manifest missing for: {$slug}"];
}
$manifest = require $manifestPath;
// Create symlinks
$symlinkResult = self::createSymlinks($slug, $manifest);
if (!$symlinkResult['success']) return $symlinkResult;
// Run install.php
$installScript = $pluginDir . '/install.php';
if (file_exists($installScript)) {
try {
require $installScript;
} catch (Throwable $e) {
return ['success' => false, 'error' => 'install.php failed: ' . $e->getMessage()];
}
}
// Upsert sp_plugins row (enabled=0 — admin must explicitly enable)
$existing = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]);
if ($existing) {
Db::update('sp_plugins', [
'name' => $manifest['name'] ?? $slug,
'version' => $manifest['version'] ?? '1.0.0',
'description' => $manifest['description'] ?? '',
'author' => $manifest['author'] ?? '',
'updated_at' => date('Y-m-d H:i:s'),
], 'slug = ?', [$slug]);
} else {
Db::insert('sp_plugins', [
'slug' => $slug,
'name' => $manifest['name'] ?? $slug,
'version' => $manifest['version'] ?? '1.0.0',
'description' => $manifest['description'] ?? '',
'author' => $manifest['author'] ?? '',
'enabled' => 0,
'installed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return ['success' => true, 'message' => "Plugin '{$slug}' installed. Enable it to activate."];
}
// ─── Enable ───────────────────────────────────────────────────────────────
/**
* Enable a plugin: ensure symlinks exist, set enabled=1.
*/
public static function enable(string $slug): array {
self::init();
$slug = self::sanitizeSlug($slug);
$row = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]);
if (!$row) return ['success' => false, 'error' => "Plugin '{$slug}' is not installed"];
$manifest = self::loadManifest($slug);
if (!$manifest) return ['success' => false, 'error' => "Cannot load manifest for '{$slug}'"];
$symlinkResult = self::createSymlinks($slug, $manifest);
if (!$symlinkResult['success']) return $symlinkResult;
Db::update('sp_plugins', ['enabled' => 1, 'updated_at' => date('Y-m-d H:i:s')], 'slug = ?', [$slug]);
return ['success' => true, 'message' => "Plugin '{$slug}' enabled"];
}
// ─── Disable ──────────────────────────────────────────────────────────────
/**
* Disable a plugin: remove symlinks, set enabled=0.
* Symlink removal causes natural 404 without any guard code.
*/
public static function disable(string $slug): array {
self::init();
$slug = self::sanitizeSlug($slug);
$row = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]);
if (!$row) return ['success' => false, 'error' => "Plugin '{$slug}' is not installed"];
self::removeSymlinks($slug);
Db::update('sp_plugins', ['enabled' => 0, 'updated_at' => date('Y-m-d H:i:s')], 'slug = ?', [$slug]);
return ['success' => true, 'message' => "Plugin '{$slug}' disabled"];
}
// ─── Uninstall ────────────────────────────────────────────────────────────
/**
* Uninstall a plugin: remove symlinks, run uninstall.php, delete DB row.
*/
public static function uninstall(string $slug): array {
self::init();
$slug = self::sanitizeSlug($slug);
self::removeSymlinks($slug);
$uninstallScript = self::$pluginsDir . '/' . $slug . '/uninstall.php';
if (file_exists($uninstallScript)) {
try {
require $uninstallScript;
} catch (Throwable $e) {
// Log but don't abort — clean up DB anyway
}
}
Db::delete('sp_plugins', ['slug' => $slug]);
return ['success' => true, 'message' => "Plugin '{$slug}' uninstalled"];
}
// ─── Discover ─────────────────────────────────────────────────────────────
/**
* Scan plugins/ directory and merge with DB rows.
* Returns array of plugin info for the admin UI.
*/
public static function discover(): array {
self::init();
// Get all DB rows keyed by slug
$dbRows = [];
$rows = Db::select("SELECT * FROM sp_plugins", []);
foreach ($rows as $row) {
$dbRows[$row['slug']] = $row;
}
$plugins = [];
if (!is_dir(self::$pluginsDir)) return $plugins;
$dirs = glob(self::$pluginsDir . '/*/plugin.php');
if (!$dirs) return $plugins;
foreach ($dirs as $manifestPath) {
$slug = basename(dirname($manifestPath));
$manifest = require $manifestPath;
$dbRow = $dbRows[$slug] ?? null;
$plugins[] = [
'slug' => $slug,
'name' => $manifest['name'] ?? $slug,
'version' => $manifest['version'] ?? '1.0.0',
'description' => $manifest['description'] ?? '',
'author' => $manifest['author'] ?? '',
'installed' => $dbRow !== null,
'enabled' => (bool)($dbRow['enabled'] ?? false),
'installed_at' => $dbRow['installed_at'] ?? null,
'plugin_id' => $dbRow['plugin_id'] ?? null,
];
}
return $plugins;
}
// ─── Helpers ──────────────────────────────────────────────────────────────
public static function pluginPath(string $slug): string {
self::init();
return self::$pluginsDir . '/' . $slug;
}
private static function sanitizeSlug(string $slug): string {
return preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($slug)));
}
private static function loadManifest(string $slug): ?array {
self::init();
$path = self::$pluginsDir . '/' . $slug . '/plugin.php';
return file_exists($path) ? require $path : null;
}
/**
* Copy plugin files into the framework directories.
* public/controllers/{slug}.php plugins/{slug}/{Slug}Controller.php
* public/views/plugins/{slug}/ plugins/{slug}/views/{slug}/
* public/assets/plugins/{slug}/ plugins/{slug}/assets/
*/
private static function createSymlinks(string $slug, array $manifest): array {
self::init();
$pluginDir = self::$pluginsDir . '/' . $slug;
$controllerClass = ucfirst($slug) . 'Controller';
// Ensure parent dirs exist
foreach ([self::$viewsDir, self::$assetsDir] as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
}
// Copy controller file
$controllerSrc = $pluginDir . '/' . $controllerClass . '.php';
$controllerDest = self::$controllersDir . '/' . $slug . '.php';
if (!file_exists($controllerSrc)) {
return ['success' => false, 'error' => "Controller not found: {$controllerClass}.php"];
}
if (!copy($controllerSrc, $controllerDest)) {
return ['success' => false, 'error' => "Failed to copy controller to: {$controllerDest}"];
}
// Copy views directory (optional)
$viewsSrc = $pluginDir . '/views/' . $slug;
$viewsDest = self::$viewsDir . '/' . $slug;
if (is_dir($viewsSrc)) {
$result = self::copyDir($viewsSrc, $viewsDest);
if (!$result) {
return ['success' => false, 'error' => "Failed to copy views to: {$viewsDest}"];
}
}
// Copy assets directory (optional)
$assetsSrc = $pluginDir . '/assets';
$assetsDest = self::$assetsDir . '/' . $slug;
if (is_dir($assetsSrc)) {
$result = self::copyDir($assetsSrc, $assetsDest);
if (!$result) {
return ['success' => false, 'error' => "Failed to copy assets to: {$assetsDest}"];
}
}
return ['success' => true];
}
/**
* Remove copied plugin files from framework directories.
*/
private static function removeSymlinks(string $slug): void {
self::init();
$controllerFile = self::$controllersDir . '/' . $slug . '.php';
if (file_exists($controllerFile)) {
unlink($controllerFile);
}
foreach ([self::$viewsDir . '/' . $slug, self::$assetsDir . '/' . $slug] as $dir) {
if (is_dir($dir)) {
self::removeDir($dir);
}
}
}
/**
* Recursively copy a directory.
*/
private static function copyDir(string $src, string $dest): bool {
if (!is_dir($dest)) {
mkdir($dest, 0755, true);
}
foreach (scandir($src) as $item) {
if ($item === '.' || $item === '..') continue;
$s = $src . '/' . $item;
$d = $dest . '/' . $item;
if (is_dir($s)) {
if (!self::copyDir($s, $d)) return false;
} else {
if (!copy($s, $d)) return false;
}
}
return true;
}
/**
* Recursively delete a directory.
*/
private static function removeDir(string $dir): void {
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
is_dir($path) ? self::removeDir($path) : unlink($path);
}
rmdir($dir);
}
}

View file

@ -0,0 +1,31 @@
<?php
/** Reference */
/**
* // Match all request URIs
[i] // Match an integer
[i:id] // Match an integer as 'id'
[a:action] // Match alphanumeric characters as 'action'
[h:key] // Match hexadecimal characters as 'key'
[:action] // Match anything up to the next / or end of the URI as 'action'
[create|edit:action] // Match either 'create' or 'edit' as 'action'
[*] // Catch all (lazy, stops at the next trailing slash)
[*:trailing] // Catch all as 'trailing' (lazy)
[**:trailing] // Catch all (possessive - will match the rest of the URI)
.[:format]? // Match an optional parameter 'format' - a / or . before the block is also optional
*/
class Router {
public static function Routing() {
$router = new AltoRouter();
$router->setBasePath('');
// Rules Set Here
// $router->map('GET|POST', '/orgs/[*:orgid]', array('c' => 'orgs', 'a' => 'index'));
PluginManager::boot($router);
return $router->match();
}
}

View file

@ -0,0 +1,26 @@
<?php
class Validate {
public static function Email($f) {
if($f) {
if (!filter_var($f, FILTER_VALIDATE_EMAIL)) {
return false;
} else {
return $f;
}
} else {
$Error['Code']='';
$Error['msg'] = "Email Required";
return (json_encode($Error));
}
}
public static function sendlog($string){
error_log($string, 0);
}
} // End Class

200
api/app/Helpers/helpers.md Normal file
View file

@ -0,0 +1,200 @@
# Helpers — `app/Helpers/`
All reusable, stateless utility functions live here as `public static` methods.
Never duplicate logic as private controller methods.
## Convention
```php
// Always call as static — no instance needed
HelperClass::methodName($arg);
```
If no existing class fits, create a new `PascalCase.php` static class here.
---
## `DB.php` — Database
All queries use prepared statements. Never interpolate values into SQL.
### Methods
```php
Db::select($sql, $params) // returns array of rows
Db::getRow($sql, $params) // returns single row or null
Db::insert($table, $data) // returns last insert ID
Db::update($table, $data, $where, $whereParams) // returns affected row count
Db::delete($table, $where, $whereParams) // returns affected row count
Db::execute($sql, $params) // for custom queries (UPDATE/INSERT/etc.)
Db::beginTransaction()
Db::commit()
Db::rollback()
Db::useConnection('legalwriter') // switch DB connection
Db::useConnection('default') // switch back
```
### Examples
```php
// SELECT multiple rows
$users = Db::select("SELECT * FROM sp_users WHERE active = :active", [':active' => 1]);
// SELECT single row
$user = Db::getRow("SELECT * FROM sp_users WHERE id = :id", [':id' => $id]);
if (!$user) { die('Not found'); }
// INSERT
$userId = Db::insert('sp_users', [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'created_at' => date('Y-m-d H:i:s')
]);
// UPDATE
Db::update('sp_users',
['name' => 'Jane Smith', 'updated_at' => date('Y-m-d H:i:s')],
'id = :id',
[':id' => $userId]
);
// DELETE
Db::delete('sp_users', 'id = :id', [':id' => $userId]);
// Soft delete (preferred)
Db::update('sp_users', ['deleted_at' => date('Y-m-d H:i:s')], 'id = :id', [':id' => $userId]);
// Custom query
Db::execute("UPDATE sp_users SET last_login = NOW() WHERE id = :id", [':id' => $id]);
// Transaction
Db::beginTransaction();
try {
$orderId = Db::insert('sp_orders', ['user_id' => $userId, 'total' => 100]);
Db::execute("UPDATE sp_users SET balance = balance - 100 WHERE id = :id", [':id' => $userId]);
Db::commit();
} catch (Exception $e) {
Db::rollback();
throw $e;
}
```
### Complex Query Patterns
```php
// Aggregation
$stats = Db::getRow("SELECT COUNT(*) as total, SUM(amount) as revenue FROM sp_orders WHERE status = 'completed'");
echo $stats['total'];
// Subquery
$users = Db::select("
SELECT u.*,
(SELECT COUNT(*) FROM sp_orders WHERE user_id = u.id) as order_count
FROM sp_users u WHERE u.active = 1
");
// Dynamic filter building
$query = "SELECT * FROM sp_clients WHERE 1=1";
$params = [];
if (!empty($_GET['q'])) {
$query .= " AND (name LIKE :q OR email LIKE :q)";
$params[':q'] = '%' . $_GET['q'] . '%';
}
if (!empty($_GET['status'])) {
$query .= " AND status = :status";
$params[':status'] = $_GET['status'];
}
$clients = Db::select($query, $params);
// Pagination
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 20;
$offset = ($page - 1) * $perPage;
$total = Db::getRow("SELECT COUNT(*) as count FROM sp_clients");
$rows = Db::select("SELECT * FROM sp_clients LIMIT :limit OFFSET :offset",
[':limit' => $perPage, ':offset' => $offset]
);
// Use EXISTS instead of COUNT for existence checks (faster)
$exists = Db::getRow("SELECT 1 FROM sp_users WHERE email = :email LIMIT 1", [':email' => $email]);
if ($exists) { ... }
```
---
## `Auth.php` — Authentication
```php
Auth::handleLogin() // redirect to /login if not authenticated
Auth::can('users.create') // returns bool — check permission
Auth::requirePermission('users.delete') // halts with 403 JSON if denied
Auth::isLoggedIn() // returns bool
Auth::getUserId() // returns current user ID from session
Auth::getUser() // returns current user row
Auth::login($userId) // set session login state
Auth::logout() // destroy session
```
---
## `Session.php` — Session Management
```php
Session::start()
Session::set($key, $value)
Session::get($key, $default = null)
Session::has($key)
Session::delete($key)
Session::destroy()
Session::flash($key, $value) // set one-time flash message
Session::getFlash($key) // get and delete flash message
```
---
## `Validator.php` — Input Validation
```php
Validator::required($value) // not empty
Validator::email($value) // valid email format
Validator::minLength($value, $min)
Validator::maxLength($value, $max)
Validator::numeric($value)
Validator::alpha($value)
Validator::alphanumeric($value)
Validator::url($value)
Validator::date($value)
```
---
## `Functions.php` — General Purpose
Key reusable helpers:
```php
// Decrypt tokenized org slug → fetch active org row (dies on invalid)
Functions::resolveOrg($slug)
// Decrypt tokenized user slug → fetch user+role row (dies on invalid)
Functions::resolveUser($slug)
// AES-256-CBC URL-safe encrypt / decrypt
Functions::encryptData($data, $key)
Functions::decryptData($token, $key)
```
---
## `Hash.php` — Cryptography
Crypto utilities (hashing, token generation, etc.).
---
## Adding New Helpers
1. Find the most semantically appropriate existing class.
2. Add a `public static function` to it.
3. If nothing fits, create `app/Helpers/NewHelper.php` as a static class.
4. Call from any controller or other helper — no instantiation needed.

View file

@ -0,0 +1,8 @@
<?php
if (!function_exists('redirect')) {
function redirect(string $url): void {
header('Location: ' . $url);
exit;
}
}

799
api/app/LLM/Anthropic.php Normal file
View file

@ -0,0 +1,799 @@
<?php
namespace App\LLM;
use Anthropic\Client;
use Anthropic\Messages\MessageParam;
use Exception;
class Anthropic
{
private Client $client;
private string $defaultModel = 'claude-sonnet-4-5-20250929';
private int $defaultMaxTokens = 4096;
private ?string $apiKey;
private string $templatePath;
public const MODELS = [
'opus' => 'claude-opus-4-20250514',
'sonnet' => 'claude-sonnet-4-5-20250929',
'haiku' => 'claude-haiku-4-5-20251001',
];
/**
* Constructor
*/
public function __construct(?string $apiKey = null, ?string $templatePath = null, int $timeout = 300) {
$this->apiKey = $apiKey ?? ANTHROPIC_API_KEY;
$this->templatePath = $templatePath ?? dirname(__DIR__, 2) . '/templates/prompts/';
if (empty($this->apiKey)) {
throw new Exception('Anthropic API key is required.');
}
if (!is_dir($this->templatePath)) {
throw new Exception("Template directory not found: {$this->templatePath}");
}
if (!is_readable($this->templatePath)) {
throw new Exception("Template directory is not readable: {$this->templatePath}");
}
set_time_limit($timeout);
ini_set('default_socket_timeout', (string) $timeout);
$this->client = new Client(apiKey: $this->apiKey);
}
// =========================================================================
// CONNECTION & HEALTH
// =========================================================================
public function testConnection(): array
{
$startTime = microtime(true);
try {
$response = $this->client->messages->create(
model: $this->defaultModel,
maxTokens: 10,
messages: [
MessageParam::with(role: 'user', content: 'Reply with only: OK')
]
);
$latency = round((microtime(true) - $startTime) * 1000);
return [
'success' => true,
'message' => 'Connected to Anthropic API',
'model' => $this->defaultModel,
'response' => trim($response->content[0]->text),
'latency_ms' => $latency,
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
]
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage(),
'error_type' => get_class($e)
];
}
}
public function isConnected(): bool
{
return $this->testConnection()['success'];
}
public function getStatus(): array
{
$connection = $this->testConnection();
return [
'connected' => $connection['success'],
'model' => $this->defaultModel,
'max_tokens' => $this->defaultMaxTokens,
'api_key_preview' => substr($this->apiKey, 0, 10) . '...' . substr($this->apiKey, -4),
'template_path' => $this->templatePath,
'connection_details' => $connection
];
}
// =========================================================================
// TEMPLATE LOADING
// =========================================================================
public function loadTemplate(string $filename): string
{
$path = $this->templatePath . '/' . $filename;
if (!file_exists($path)) {
throw new Exception("Template not found: {$path}");
}
$content = file_get_contents($path);
if ($content === false) {
throw new Exception("Failed to read template: {$path}");
}
return $content;
}
public function setTemplatePath(string $path): self
{
$this->templatePath = rtrim($path, '/');
return $this;
}
public function getTemplatePath(): string
{
return $this->templatePath;
}
// =========================================================================
// BASIC MESSAGING
// =========================================================================
public function message(string $prompt, ?string $systemPrompt = null, array $options = []): string
{
$response = $this->messageWithMeta($prompt, $systemPrompt, $options);
return $response['content'];
}
public function messageWithMeta(string $prompt, ?string $systemPrompt = null, array $options = []): array
{
$startTime = microtime(true);
$messages = $this->buildMessages($prompt, $options['conversation'] ?? []);
$params = [
'model' => $options['model'] ?? $this->defaultModel,
'maxTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
'messages' => $messages,
];
if ($systemPrompt) {
$params['system'] = $systemPrompt;
}
if (isset($options['temperature'])) {
$params['temperature'] = $options['temperature'];
}
if (isset($options['stop_sequences'])) {
$params['stopSequences'] = $options['stop_sequences'];
}
$response = $this->client->messages->create(...$params);
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'stop_reason' => $response->stopReason,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'total_tokens' => $response->usage->inputTokens + $response->usage->outputTokens,
]
];
}
public function conversation(array $messages, ?string $systemPrompt = null, array $options = []): array
{
$messageParams = [];
foreach ($messages as $msg) {
$messageParams[] = MessageParam::with(role: $msg['role'], content: $msg['content']);
}
$params = [
'model' => $options['model'] ?? $this->defaultModel,
'maxTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
'messages' => $messageParams,
];
if ($systemPrompt) {
$params['system'] = $systemPrompt;
}
$response = $this->client->messages->create(...$params);
return [
'content' => $response->content[0]->text,
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
]
];
}
// =========================================================================
// CACHED MESSAGING
// =========================================================================
/**
* Message with prompt caching - template is cached, variables are not
*/
public function messageWithCache(
string $prompt,
string $cachedContext,
?string $dynamicContext = null,
array $options = []
): array {
$startTime = microtime(true);
$systemBlocks = [
[
'type' => 'text',
'text' => $cachedContext,
'cache_control' => ['type' => 'ephemeral']
]
];
if ($dynamicContext) {
$systemBlocks[] = [
'type' => 'text',
'text' => $dynamicContext
];
}
$response = $this->client->messages->create(
model: $options['model'] ?? $this->defaultModel,
maxTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
system: $systemBlocks,
messages: [
MessageParam::with(role: 'user', content: $prompt)
]
);
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'cache_creation_tokens' => $cacheCreation,
'cache_read_tokens' => $cacheRead,
],
'cache_hit' => $cacheRead > 0,
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead)
];
}
private function getCacheStatus(int $created, int $read): string
{
if ($read > 0) return 'hit';
if ($created > 0) return 'created';
return 'none';
}
// =========================================================================
// ARTICLE GENERATION
// =========================================================================
/**
* Build the dynamic variables block (topic, word count, etc.)
* This is NOT cached - changes per article
*/
public function buildArticleVariables(array $config): string
{
$lines = [];
if (!empty($config['topic'])) {
$lines[] = "TOPIC: {$config['topic']}";
}
if (!empty($config['domain'])) {
$lines[] = "DOMAIN: {$config['domain']}";
}
if (!empty($config['industry'])) {
$lines[] = "INDUSTRY: {$config['industry']}";
}
if (!empty($config['target_audience'])) {
$lines[] = "AUDIENCE: {$config['target_audience']}";
}
if (!empty($config['knowledge_level'])) {
$lines[] = "KNOWLEDGE_LEVEL: {$config['knowledge_level']}";
}
if (!empty($config['tone'])) {
$lines[] = "TONE: {$config['tone']}";
}
if (!empty($config['word_count'])) {
$lines[] = "WORD_COUNT: {$config['word_count']}";
}
if (!empty($config['location'])) {
$lines[] = "LOCATION: {$config['location']}";
}
$lines[] = "CURRENT_DATE: " . date('Y-m-d');
return implode("\n", $lines);
}
/**
* Generate article using template + variables (legacy single-file method)
* Template (with business profile) is cached
* Variables (topic, word count, etc.) are dynamic
*/
public function generateArticle(string $templateFile, array $config): array
{
// Load template - this includes the master prompt + business profile
// This part gets cached
$template = $this->loadTemplate($templateFile);
// Build dynamic variables - this changes per article
$variables = $this->buildArticleVariables($config);
return $this->messageWithCache(
prompt: $config['prompt'] ?? 'BEGIN GENERATION NOW.',
cachedContext: $template,
dynamicContext: $variables,
options: [
'max_tokens' => $config['max_tokens'] ?? 8192,
'model' => $config['model'] ?? $this->defaultModel
]
);
}
/**
* Generate article using 3-block structure with prompt caching
*
* Block 1: Business Profile (cached - client specific)
* Block 2: Article Instructions (cached - shared across all clients)
* Block 3: Config Variables (dynamic - not cached)
*
* Cache behavior:
* - Blocks 1 & 2 are cached together as a prefix
* - When processing multiple articles for same client, cache hits on blocks 1+2
* - Block 3 varies per article, never cached
*
* @param string $businessProfile Client-specific business profile content
* @param string $articleInstructions Shared writing instructions content
* @param array $config Article configuration (topic, word_count, etc.)
* - 'enable_web_search' => bool (default: false)
* - 'reference_urls' => array of URLs to research
* @return array Response with content, usage, and cache status
*/
// =============================================================================
// UPDATED Anthropic::generateArticleWithBlocks()
// =============================================================================
public function generateArticleWithBlocks(
string $businessProfile,
string $articleInstructions,
array $projectConfig,
string $referenceContent = '',
bool $useWebSearch = false
): array {
$startTime = microtime(true);
// Build article variables (Block 3)
$articleVariables = $this->buildArticleVariables($projectConfig);
// DEEPCRAWL MODE: Inject pre-fetched content into Block 3
if (!empty($referenceContent)) {
$articleVariables .= "\n\n" . $referenceContent;
}
// WEBSEARCH MODE: Add URL hints for Claude to search
if ($useWebSearch && !empty($projectConfig['reference_urls'])) {
$urlHints = "\n\n<search_guidance>\n";
$urlHints .= "Consider searching these sources for authoritative information:\n";
foreach ($projectConfig['reference_urls'] as $ref) {
$url = is_array($ref) ? $ref['url'] : $ref;
$notes = is_array($ref) ? ($ref['notes'] ?? '') : '';
$urlHints .= "- {$url}";
if ($notes) $urlHints .= " ({$notes})";
$urlHints .= "\n";
}
$urlHints .= "</search_guidance>";
$articleVariables .= $urlHints;
}
// =========================================================================
// BUILD THE FULL PROMPT (for logging)
// =========================================================================
$block1 = "<business_profile>\n{$businessProfile}\n</business_profile>";
$block2 = "<article_instructions>\n{$articleInstructions}\n</article_instructions>";
$block3 = "<article_request>\n{$articleVariables}\n</article_request>";
$fullPrompt = $block1 . "\n\n" . $block2 . "\n\n" . $block3;
// Build messages
$messages = [
[
'role' => 'user',
'content' => [
// Block 1: Business Profile (cached)
[
'type' => 'text',
'text' => $block1,
'cache_control' => ['type' => 'ephemeral']
],
// Block 2: Article Instructions (cached)
[
'type' => 'text',
'text' => $block2,
'cache_control' => ['type' => 'ephemeral']
],
// Block 3: Article Variables + References (dynamic)
[
'type' => 'text',
'text' => $block3
]
]
]
];
// Call API
if ($useWebSearch) {
$response = $this->client->messages->create(
model: 'claude-sonnet-4-5-20250929',
maxTokens: 8192,
messages: $messages,
tools: [
[
'type' => 'web_search_20250305',
'name' => 'web_search',
]
]
);
} else {
$response = $this->client->messages->create(
model: 'claude-sonnet-4-5-20250929',
maxTokens: 8192,
messages: $messages
);
}
// Parse response
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'stop_reason' => $response->stopReason,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'cache_creation_tokens' => $cacheCreation,
'cache_read_tokens' => $cacheRead,
],
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead),
'prompt_sent' => $fullPrompt, // NEW: Full prompt for logging
];
}
/**
* Build instruction for reference URLs
*/
private function buildReferenceUrlsInstruction(array $urls): string
{
$instruction = "REFERENCE URLS FOR RESEARCH:\n";
$instruction .= "Search and reference the following URLs for accurate, current information:\n\n";
foreach ($urls as $index => $url) {
$num = $index + 1;
if (is_array($url)) {
// URL with description: ['url' => '...', 'description' => '...']
$instruction .= "{$num}. {$url['url']}\n";
if (!empty($url['description'])) {
$instruction .= " Purpose: {$url['description']}\n";
}
} else {
// Simple URL string
$instruction .= "{$num}. {$url}\n";
}
}
$instruction .= "\nUse web search to fetch current information from these sources. ";
$instruction .= "Cite specific facts, statistics, or requirements found on these pages. ";
$instruction .= "You may also search for additional supporting information as needed.";
return $instruction;
}
/**
* Extract text content from response blocks (handles mixed content with tool use)
*/
private function extractTextContent(array $contentBlocks): string
{
$textParts = [];
foreach ($contentBlocks as $block) {
if (isset($block->type) && $block->type === 'text') {
$textParts[] = $block->text;
}
}
return implode("\n", $textParts);
}
/**
* Generate article using multi-turn conversation style
*
* This approach chunks the prompt into conversational turns:
* - Turn 1: User sends business profile
* - Turn 2: Assistant acknowledges persona
* - Turn 3: User sends article instructions
* - Turn 4: Assistant confirms understanding
* - Turn 5: User sends config and triggers generation
*
* Benefits: May improve instruction following for complex prompts
* Tradeoff: Extra tokens for assistant acknowledgments
*
* @param string $businessProfile Client-specific business profile content
* @param string $articleInstructions Shared writing instructions content
* @param array $config Article configuration (topic, word_count, etc.)
* @return array Response with content, usage, and cache status
*/
public function generateArticleMultiTurn(
string $businessProfile,
string $articleInstructions,
array $config
): array {
$startTime = microtime(true);
// Build dynamic config variables
$configVariables = $this->buildArticleVariables($config);
$response = $this->client->messages->create(
model: $config['model'] ?? $this->defaultModel,
maxTokens: $config['max_tokens'] ?? 8192,
messages: [
// Turn 1: Business Profile
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => "Here is the business profile you will write as:\n\n" . $businessProfile,
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 2: Assistant acknowledges persona
[
'role' => 'assistant',
'content' => [
[
'type' => 'text',
'text' => "I understand. I am now embodying this business identity and will write from their perspective, using their voice, expertise, and experience as defined in the profile.",
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 3: Article Instructions
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => "Here are the article writing instructions:\n\n" . $articleInstructions,
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 4: Assistant confirms understanding
[
'role' => 'assistant',
'content' => [
[
'type' => 'text',
'text' => "Ready. I will follow these writing rules: early answer with recommendation, 'This breaks down when...' statement, field notes from practitioner experience, no blacklisted phrases, boundary conditions section, and output valid JSON only.",
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 5: Config Variables + Generate
[
'role' => 'user',
'content' => $configVariables . "\n\nBEGIN GENERATION.",
],
],
);
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'stop_reason' => $response->stopReason,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'cache_creation_tokens' => $cacheCreation,
'cache_read_tokens' => $cacheRead,
],
'cache_hit' => $cacheRead > 0,
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead),
];
}
/**
* Generate multiple articles (same template = cache hits after first)
*/
public function generateArticleBatch(string $templateFile, array $articles): array
{
$results = [];
foreach ($articles as $index => $config) {
$results[] = [
'index' => $index,
'topic' => $config['topic'] ?? 'Unknown',
'result' => $this->generateArticle($templateFile, $config)
];
}
return $results;
}
// =========================================================================
// UTILITY METHODS
// =========================================================================
private function buildMessages(string $prompt, array $conversation = []): array
{
$messages = [];
foreach ($conversation as $msg) {
$messages[] = MessageParam::with(role: $msg['role'], content: $msg['content']);
}
$messages[] = MessageParam::with(role: 'user', content: $prompt);
return $messages;
}
public function estimateTokens(string $text): int
{
return (int) ceil(strlen($text) / 4);
}
/**
* Check if content meets minimum cache threshold (1024 tokens for Sonnet)
*/
public function meetsCacheMinimum(string $text): bool
{
return $this->estimateTokens($text) >= 1024;
}
public function estimateCost(int $inputTokens, int $outputTokens, ?string $model = null): array
{
$model = $model ?? $this->defaultModel;
$pricing = [
'claude-opus-4-20250514' => ['input' => 15.00, 'output' => 75.00],
'claude-sonnet-4-5-20250929' => ['input' => 3.00, 'output' => 15.00],
'claude-haiku-4-5-20251001' => ['input' => 0.80, 'output' => 4.00],
];
$rates = $pricing[$model] ?? $pricing['claude-sonnet-4-5-20250929'];
$inputCost = ($inputTokens / 1_000_000) * $rates['input'];
$outputCost = ($outputTokens / 1_000_000) * $rates['output'];
return [
'input_cost' => round($inputCost, 6),
'output_cost' => round($outputCost, 6),
'total_cost' => round($inputCost + $outputCost, 6),
'model' => $model
];
}
public function formatCost(array $usage): string {
$cost = $this->estimateCostWithCache($usage);
$output = "Cost Breakdown:\n";
$output .= " Input: $" . number_format($cost['input_cost'], 4) . "\n";
$output .= " Output: $" . number_format($cost['output_cost'], 4) . "\n";
if ($cost['cache_write_cost'] > 0) {
$output .= " Cache Write: $" . number_format($cost['cache_write_cost'], 4) . "\n";
}
if ($cost['cache_read_cost'] > 0) {
$output .= " Cache Read: $" . number_format($cost['cache_read_cost'], 4) . "\n";
}
$output .= " ─────────────\n";
$output .= " Total: $" . number_format($cost['total_cost'], 4) . "\n";
if ($cost['savings'] > 0) {
$output .= " Saved: $" . number_format($cost['savings'], 4) . " ({$cost['savings_percent']}%)\n";
}
return $output;
}
public function estimateCostWithCache(array $usage, ?string $model = null): array
{
$model = $model ?? $this->defaultModel;
$pricing = [
'claude-opus-4-20250514' => ['input' => 15.00, 'output' => 75.00, 'cache_write' => 18.75, 'cache_read' => 1.50],
'claude-sonnet-4-5-20250929' => ['input' => 3.00, 'output' => 15.00, 'cache_write' => 3.75, 'cache_read' => 0.30],
'claude-haiku-4-5-20251001' => ['input' => 0.80, 'output' => 4.00, 'cache_write' => 1.00, 'cache_read' => 0.08],
];
$rates = $pricing[$model] ?? $pricing['claude-sonnet-4-5-20250929'];
$inputCost = (($usage['input_tokens'] ?? 0) / 1_000_000) * $rates['input'];
$outputCost = (($usage['output_tokens'] ?? 0) / 1_000_000) * $rates['output'];
$cacheWriteCost = (($usage['cache_creation_tokens'] ?? 0) / 1_000_000) * $rates['cache_write'];
$cacheReadCost = (($usage['cache_read_tokens'] ?? 0) / 1_000_000) * $rates['cache_read'];
$totalCost = $inputCost + $outputCost + $cacheWriteCost + $cacheReadCost;
$cacheTokens = ($usage['cache_creation_tokens'] ?? 0) + ($usage['cache_read_tokens'] ?? 0);
$costWithoutCache = (($usage['input_tokens'] + $cacheTokens) / 1_000_000) * $rates['input'] + $outputCost;
$savings = $costWithoutCache - $totalCost;
return [
'input_cost' => round($inputCost, 6),
'output_cost' => round($outputCost, 6),
'cache_write_cost' => round($cacheWriteCost, 6),
'cache_read_cost' => round($cacheReadCost, 6),
'total_cost' => round($totalCost, 6),
'cost_without_cache' => round($costWithoutCache, 6),
'savings' => round($savings, 6),
'savings_percent' => $costWithoutCache > 0 ? round(($savings / $costWithoutCache) * 100, 2) : 0,
'model' => $model
];
}
// =========================================================================
// CONFIGURATION
// =========================================================================
public function setModel(string $model): self
{
$this->defaultModel = $model;
return $this;
}
public function useModel(string $preset): self
{
if (isset(self::MODELS[$preset])) {
$this->defaultModel = self::MODELS[$preset];
}
return $this;
}
public function setMaxTokens(int $tokens): self
{
$this->defaultMaxTokens = $tokens;
return $this;
}
public function getModel(): string
{
return $this->defaultModel;
}
public function getClient(): Client
{
return $this->client;
}
public function getAvailableModels(): array
{
return self::MODELS;
}
}

View file

@ -0,0 +1,749 @@
<?php
namespace App\LLM;
use Exception;
use Gemini as GeminiClient;
use Gemini\Data\Content;
use Gemini\Data\GenerationConfig;
use Gemini\Data\Tool;
use Gemini\Data\GoogleSearch;
use Gemini\Enums\ResponseMimeType;
class GeminiService {
private $client;
private string $defaultModel = 'gemini-3-pro-preview';
private int $defaultMaxTokens = 8192;
private ?string $apiKey;
private string $templatePath;
// Model presets for different use cases
// - pro: Best for humanizing text, creative nuance, SEO strategy, and fact-checking
// - flash: Best for bulk processing, initial drafts, AEO structure checks
// - flash-lite: Cost saver for simple tasks like meta-tag generation or keyword extraction
public const MODELS = [
'pro' => 'gemini-3-pro-preview', // Best for humanizing, SEO strategy
'flash' => 'gemini-3-flash-preview', // Faster, good for SEO review pass
'flash-lite' => 'gemini-2.5-flash-lite', // Cost saver for simple tasks
'flash-2.5' => 'gemini-2.5-flash', // Legacy flash model
];
// Human-readable labels for UI dropdowns
public const MODEL_LABELS = [
'gemini-3-pro-preview' => 'Gemini 3 Pro (Best for Humanizing)',
'gemini-3-flash-preview' => 'Gemini 3 Flash (Fast & Smart)',
'gemini-2.5-flash' => 'Gemini 2.5 Flash (Legacy)',
'gemini-2.5-flash-lite' => 'Gemini 2.5 Flash Lite (Budget)',
];
/**
* Constructor
*/
public function __construct(?string $apiKey = null, ?string $templatePath = null, int $timeout = 300)
{
$this->apiKey = $apiKey ?? (defined('GEMINI_API_KEY') ? GEMINI_API_KEY : null);
if (empty($this->apiKey)) {
throw new Exception('Gemini API key is required.');
}
$this->templatePath = $templatePath ?? '/www/wwwroot/appCarlos/templates/prompts';
if (!is_dir($this->templatePath)) {
throw new Exception("Template directory not found: {$this->templatePath}");
}
if (!is_readable($this->templatePath)) {
throw new Exception("Template directory is not readable: {$this->templatePath}");
}
set_time_limit($timeout);
ini_set('default_socket_timeout', (string) $timeout);
$this->client = GeminiClient::client($this->apiKey);
}
// =========================================================================
// CONNECTION & HEALTH
// =========================================================================
public function testConnection(): array
{
$startTime = microtime(true);
try {
$response = $this->client
->generativeModel(model: $this->defaultModel)
->generateContent('Reply with only: OK');
$latency = round((microtime(true) - $startTime) * 1000);
// Extract usage from response
$usage = $this->extractUsage($response);
return [
'success' => true,
'message' => 'Connected to Gemini API',
'model' => $this->defaultModel,
'response' => trim($response->text()),
'latency_ms' => $latency,
'usage' => $usage,
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage(),
'error_type' => get_class($e),
];
}
}
public function isConnected(): bool
{
return $this->testConnection()['success'];
}
public function getStatus(): array
{
$connection = $this->testConnection();
return [
'connected' => $connection['success'],
'model' => $this->defaultModel,
'max_tokens' => $this->defaultMaxTokens,
'api_key_preview' => substr($this->apiKey, 0, 10) . '...' . substr($this->apiKey, -4),
'template_path' => $this->templatePath,
'connection_details' => $connection,
];
}
// =========================================================================
// TEMPLATE LOADING
// =========================================================================
public function loadTemplate(string $filename): string
{
$path = $this->templatePath . '/' . $filename;
if (!file_exists($path)) {
throw new Exception("Template not found: {$path}");
}
$content = file_get_contents($path);
if ($content === false) {
throw new Exception("Failed to read template: {$path}");
}
return $content;
}
public function setTemplatePath(string $path): self
{
$this->templatePath = rtrim($path, '/');
return $this;
}
public function getTemplatePath(): string
{
return $this->templatePath;
}
// =========================================================================
// BASIC MESSAGING
// =========================================================================
public function message(string $prompt, ?string $systemPrompt = null, array $options = []): string
{
$response = $this->messageWithMeta($prompt, $systemPrompt, $options);
return $response['content'];
}
/**
* GEMINI SERVICE UPDATE
*
* Replace the existing messageWithMeta() method with this updated version
* that supports JSON response mode via 'response_mime_type' option.
*
* Location: App\Components\GeminiService
* Method: messageWithMeta()
*/
public function messageWithMeta(string $prompt, ?string $systemPrompt = null, array $options = []): array
{
$startTime = microtime(true);
$model = $this->client->generativeModel(
model: $options['model'] ?? $this->defaultModel
);
// Add system instruction if provided
if ($systemPrompt) {
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
}
// Build generation config
$configParams = [
'maxOutputTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
];
if (isset($options['response_mime_type']) && $options['response_mime_type'] === 'application/json') {
$configParams['responseMimeType'] = ResponseMimeType::APPLICATION_JSON;
}
if (isset($options['temperature'])) {
$configParams['temperature'] = $options['temperature'];
}
if (isset($options['stop_sequences'])) {
$configParams['stopSequences'] = $options['stop_sequences'];
}
// Support JSON response mode
if (isset($options['response_mime_type']) && $options['response_mime_type'] === 'application/json') {
$configParams['responseMimeType'] = ResponseMimeType::APPLICATION_JSON;
}
$model = $model->withGenerationConfig(new GenerationConfig(...$configParams));
// Enable Google Search if requested
if ($options['use_search'] ?? false) {
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
}
$response = $model->generateContent($prompt);
$usage = $this->extractUsage($response);
return [
'content' => $response->text(),
'model' => $options['model'] ?? $this->defaultModel,
'stop_reason' => $response->candidates[0]->finishReason ?? null,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => $usage,
];
}
// =========================================================================
// ARTICLE GENERATION
// =========================================================================
/**
* Build the dynamic variables block (topic, word count, etc.)
*/
public function buildArticleVariables(array $config): string
{
$lines = [];
if (!empty($config['topic'])) {
$lines[] = "TOPIC: {$config['topic']}";
}
if (!empty($config['domain'])) {
$lines[] = "DOMAIN: {$config['domain']}";
}
if (!empty($config['industry'])) {
$lines[] = "INDUSTRY: {$config['industry']}";
}
if (!empty($config['target_audience'])) {
$lines[] = "AUDIENCE: {$config['target_audience']}";
}
if (!empty($config['knowledge_level'])) {
$lines[] = "KNOWLEDGE_LEVEL: {$config['knowledge_level']}";
}
if (!empty($config['tone'])) {
$lines[] = "TONE: {$config['tone']}";
}
if (!empty($config['word_count'])) {
$lines[] = "WORD_COUNT: {$config['word_count']}";
}
if (!empty($config['location'])) {
$lines[] = "LOCATION: {$config['location']}";
}
return implode("\n", $lines);
}
/**
* Generate article using template + variables
* Uses Google Search for fact verification
*/
public function generateArticle(string $templateFile, array $config): array
{
$startTime = microtime(true);
// Load template - this includes the master prompt + business profile
$template = $this->loadTemplate($templateFile);
// Build dynamic variables
$variables = $this->buildArticleVariables($config);
// Combine template with variables
$systemPrompt = $template . "\n\n" . $variables;
// Build the model with configuration
$model = $this->client->generativeModel(
model: $config['model'] ?? $this->defaultModel
);
// Add system instruction (template + variables)
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
// Add generation config
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $config['max_tokens'] ?? $this->defaultMaxTokens,
));
// Enable Google Search for fact verification
if ($config['use_search'] ?? true) {
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
}
// Generate content
$userPrompt = $config['prompt'] ?? 'BEGIN GENERATION NOW.';
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
// Check if grounding was used
$groundingUsed = $this->checkGroundingUsed($response);
return [
'content' => $response->text(),
'model' => $config['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'grounding_used' => $groundingUsed,
'usage' => $usage,
];
}
/**
* Generate article with structured JSON output
*/
public function generateArticleStructured(string $templateFile, array $config): array
{
$startTime = microtime(true);
// Load template
$template = $this->loadTemplate($templateFile);
// Build dynamic variables
$variables = $this->buildArticleVariables($config);
// Define JSON structure for article
$jsonSchema = $config['json_schema'] ?? [
'title' => 'Article title',
'meta_description' => 'SEO meta description',
'content' => 'Full article content in HTML format',
'sections' => [
['heading' => 'Section heading', 'content' => 'Section content']
],
'tags' => ['relevant', 'tags'],
'sources' => [
['title' => 'Source title', 'url' => 'Source URL']
],
];
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
// Combine template with variables and JSON instruction
$systemPrompt = $template . "\n\n" . $variables . "\n\n" .
"IMPORTANT: Respond ONLY with valid JSON using this structure:\n" . $schemaJson;
// Build the model
$model = $this->client->generativeModel(
model: $config['model'] ?? $this->defaultModel
);
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
// Enable JSON output
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $config['max_tokens'] ?? $this->defaultMaxTokens,
responseMimeType: ResponseMimeType::APPLICATION_JSON,
));
// Enable Google Search
if ($config['use_search'] ?? true) {
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
}
$userPrompt = $config['prompt'] ?? 'BEGIN GENERATION NOW.';
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
$groundingUsed = $this->checkGroundingUsed($response);
// Parse JSON response
$articleData = null;
try {
$articleData = $response->json();
} catch (Exception $e) {
// Fall back to text if JSON parsing fails
$articleData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
}
return [
'content' => $response->text(),
'article' => $articleData,
'model' => $config['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'grounding_used' => $groundingUsed,
'usage' => $usage,
];
}
/**
* Generate multiple articles
*/
public function generateArticleBatch(string $templateFile, array $articles): array
{
$results = [];
foreach ($articles as $index => $config) {
$results[] = [
'index' => $index,
'topic' => $config['topic'] ?? 'Unknown',
'result' => $this->generateArticle($templateFile, $config),
];
}
return $results;
}
// =========================================================================
// ARTICLE REVIEW
// =========================================================================
/**
* Review an article for quality and factual accuracy
*/
public function reviewArticle(string $articleContent, array $options = []): array
{
$startTime = microtime(true);
$criteria = $options['criteria'] ?? [
'grammar_spelling',
'factual_accuracy',
'readability',
'seo_optimization',
'engagement',
'structure',
'tone_consistency',
];
$criteriaList = implode(', ', $criteria);
$systemPrompt = <<<PROMPT
You are an expert editor and content quality analyst. Your task is to thoroughly review articles
and provide detailed, actionable feedback. Use Google Search to verify any factual claims made
in the article. Be thorough but constructive.
PROMPT;
$jsonSchema = [
'overall_score' => 85,
'summary' => 'Brief overall assessment',
'criteria_scores' => [
'example_criterion' => [
'score' => 90,
'feedback' => 'Detailed feedback',
],
],
'strengths' => ['List of article strengths'],
'improvements' => [
[
'priority' => 'high|medium|low',
'issue' => 'Issue description',
'suggestion' => 'How to fix it',
],
],
'fact_check' => [
[
'claim' => 'Claim from the article',
'verified' => true,
'source' => 'Source or note',
],
],
];
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
$userPrompt = <<<PROMPT
Review the following article and provide a comprehensive quality assessment.
ARTICLE TO REVIEW:
{$articleContent}
Evaluate on these criteria: {$criteriaList}
Use Google Search to verify factual claims.
Respond with JSON in this structure:
{$schemaJson}
PROMPT;
$model = $this->client->generativeModel(
model: $options['model'] ?? $this->defaultModel
);
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
responseMimeType: ResponseMimeType::APPLICATION_JSON,
));
// Enable Google Search for fact-checking
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
$reviewData = null;
try {
$reviewData = $response->json();
} catch (Exception $e) {
$reviewData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
}
return [
'content' => $response->text(),
'review' => $reviewData,
'model' => $options['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'usage' => $usage,
];
}
// =========================================================================
// RESEARCH
// =========================================================================
/**
* Research a topic using Google Search
*/
public function research(string $query, array $options = []): array
{
$startTime = microtime(true);
$depth = $options['depth'] ?? 2;
$depthInstructions = match ($depth) {
1 => 'Provide a quick overview with 3-5 key points.',
2 => 'Provide a comprehensive overview with detailed findings.',
3 => 'Provide an exhaustive analysis covering all aspects.',
default => 'Provide a comprehensive overview.',
};
$systemPrompt = <<<PROMPT
You are a research assistant. Research topics thoroughly using Google Search.
Verify information across multiple sources. Be accurate and cite sources.
PROMPT;
$jsonSchema = [
'topic' => 'The research topic',
'summary' => 'Executive summary',
'key_findings' => [
['finding' => 'Key finding', 'confidence' => 'high|medium|low'],
],
'statistics' => [
['stat' => 'Statistic', 'source' => 'Source'],
],
'sources' => [
['title' => 'Source title', 'url' => 'URL', 'credibility' => 'high|medium|low'],
],
];
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
$userPrompt = <<<PROMPT
Research: {$query}
{$depthInstructions}
Use Google Search to find current and accurate information.
Respond with JSON:
{$schemaJson}
PROMPT;
$model = $this->client->generativeModel(
model: $options['model'] ?? $this->defaultModel
);
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
responseMimeType: ResponseMimeType::APPLICATION_JSON,
));
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
$researchData = null;
try {
$researchData = $response->json();
} catch (Exception $e) {
$researchData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
}
return [
'content' => $response->text(),
'research' => $researchData,
'model' => $options['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'usage' => $usage,
];
}
// =========================================================================
// UTILITY METHODS
// =========================================================================
/**
* Extract usage information from response
*/
private function extractUsage($response): array
{
$usage = [
'input_tokens' => 0,
'output_tokens' => 0,
'total_tokens' => 0,
];
// Try to get usage metadata from response
if (isset($response->usageMetadata)) {
$usage['input_tokens'] = $response->usageMetadata->promptTokenCount ?? 0;
$usage['output_tokens'] = $response->usageMetadata->candidatesTokenCount ?? 0;
$usage['total_tokens'] = $response->usageMetadata->totalTokenCount ??
($usage['input_tokens'] + $usage['output_tokens']);
}
return $usage;
}
/**
* Check if grounding/search was used in the response
*/
private function checkGroundingUsed($response): bool
{
// Check for grounding metadata in response
if (isset($response->candidates[0]->groundingMetadata)) {
return true;
}
return false;
}
public function estimateTokens(string $text): int
{
return (int) ceil(strlen($text) / 4);
}
public function estimateCost(array $usage, ?string $model = null): array
{
$model = $model ?? $this->defaultModel;
// Gemini pricing per 1M tokens (as of January 2026)
$pricing = [
'gemini-3-pro-preview' => ['input' => 2.00, 'output' => 12.00],
'gemini-3-flash-preview' => ['input' => 0.20, 'output' => 0.80],
'gemini-2.5-flash' => ['input' => 0.15, 'output' => 0.60],
'gemini-2.5-flash-lite' => ['input' => 0.075, 'output' => 0.30],
];
$rates = $pricing[$model] ?? $pricing['gemini-3-pro-preview'];
$inputCost = (($usage['input_tokens'] ?? 0) / 1_000_000) * $rates['input'];
$outputCost = (($usage['output_tokens'] ?? 0) / 1_000_000) * $rates['output'];
return [
'input_cost' => round($inputCost, 6),
'output_cost' => round($outputCost, 6),
'total_cost' => round($inputCost + $outputCost, 6),
'model' => $model,
];
}
public function formatCost(array $usage): string
{
$cost = $this->estimateCost($usage);
$output = "Cost Breakdown:\n";
$output .= " Input: $" . number_format($cost['input_cost'], 4) . "\n";
$output .= " Output: $" . number_format($cost['output_cost'], 4) . "\n";
$output .= " ─────────────\n";
$output .= " Total: $" . number_format($cost['total_cost'], 4) . "\n";
return $output;
}
// =========================================================================
// CONFIGURATION
// =========================================================================
public function setModel(string $model): self
{
$this->defaultModel = $model;
return $this;
}
public function useModel(string $preset): self
{
if (isset(self::MODELS[$preset])) {
$this->defaultModel = self::MODELS[$preset];
}
return $this;
}
public function setMaxTokens(int $tokens): self
{
$this->defaultMaxTokens = $tokens;
return $this;
}
public function getModel(): string
{
return $this->defaultModel;
}
public function getClient()
{
return $this->client;
}
public function getAvailableModels(): array
{
return self::MODELS;
}
/**
* Get model labels for UI dropdowns
*/
public static function getModelLabels(): array
{
return self::MODEL_LABELS;
}
/**
* Get model ID from preset name
*/
public static function getModelId(string $preset): string
{
return self::MODELS[$preset] ?? $preset;
}
}

254
api/app/LLM/LLMManager.php Normal file
View file

@ -0,0 +1,254 @@
<?php
namespace App\LLM;
/**
* Loads LLM provider config and returns a configured, ready-to-use provider instance.
*
* Two scopes:
* Application-level stored in sp_settings (group='llm'), admin-managed, shared across all orgs.
* Org-level stored in sp_orgs_meta, per-org overrides (optional future use).
*
* Usage:
* $llm = LLMManager::forApp('anthropic'); // application-level (sp_settings)
* $llm = LLMManager::forOrg($orgId, 'openai'); // org-level (sp_orgs_meta)
*/
class LLMManager
{
// Maps provider key to provider class
private static array $providers = [
'openai' => svcOpenAI::class,
'anthropic' => svcAnthropic::class,
'gemini' => svcGemini::class,
'mistral' => svcMistral::class,
'deepseek' => svcDeepSeek::class,
];
// Maps provider key to sp_settings.keyval (application-level)
private static array $settingsKeys = [
'openai' => 'llm_openai',
'anthropic' => 'llm_anthropic',
'gemini' => 'llm_gemini',
'mistral' => 'llm_mistral',
'deepseek' => 'llm_deepseek',
];
// Maps provider key to sp_orgs_meta.keyval (org-level overrides)
private static array $metaKeys = [
'openai' => 'llmOpenAI',
'anthropic' => 'llmAnthropic',
'gemini' => 'llmGemini',
'mistral' => 'llmMistral',
'deepseek' => 'llmDeepSeek',
];
// Provider preference order for auto-selection
public static array $priority = ['anthropic', 'openai', 'gemini', 'mistral', 'deepseek'];
/**
* Instantiate a provider directly from a pre-loaded config array.
* Use this when you already have the config and don't need another DB lookup.
*
* @throws \Exception if provider unknown
*/
public static function make(string $provider, array $config): LLMProvider
{
$provider = strtolower($provider);
if (!isset(self::$providers[$provider])) {
throw new \Exception("Unknown LLM provider: '$provider'");
}
$class = self::$providers[$provider];
return new $class($config);
}
// ─── Application-level (sp_settings) ────────────────────────────────────
/**
* Return a configured provider instance from the application-level sp_settings table.
*
* @throws \Exception if provider unknown or not configured
*/
public static function forApp(string $provider): LLMProvider
{
$provider = strtolower($provider);
if (!isset(self::$providers[$provider])) {
throw new \Exception("Unknown LLM provider: '$provider'");
}
$config = self::getAppConfig($provider);
if (empty($config)) {
throw new \Exception("No '$provider' configuration found. Add one under Admin → Integrations.");
}
$class = self::$providers[$provider];
return new $class($config);
}
/**
* Read and normalize a provider's config from sp_settings.
* Returns [] if not found or api_key is empty.
*/
public static function getAppConfig(string $provider): array
{
$provider = strtolower($provider);
$settingsKey = self::$settingsKeys[$provider] ?? null;
if (!$settingsKey) return [];
$row = \Db::getRow(
"SELECT metval FROM sp_settings WHERE `group` = 'llm' AND keyval = ? LIMIT 1",
[$settingsKey]
);
if (!$row || empty($row['metval'])) return [];
$raw = json_decode($row['metval'], true);
if (!is_array($raw) || empty($raw['api_key'])) return [];
// Normalize sp_settings field names to LLMProvider expected names
return array_filter([
'secretKey' => $raw['api_key'],
'model' => $raw['model'] ?? null,
'max_tokens' => $raw['max_tokens'] ?? null,
'temperature' => $raw['temperature'] ?? null,
]);
}
/**
* Return the list of application-level providers that have a non-empty api_key.
*
* @return array e.g. ['anthropic', 'openai']
*/
public static function getAppProviders(): array
{
$available = [];
foreach (self::$settingsKeys as $provider => $keyval) {
$row = \Db::getRow(
"SELECT metval FROM sp_settings WHERE `group` = 'llm' AND keyval = ? LIMIT 1",
[$keyval]
);
if (!$row || empty($row['metval'])) continue;
$cfg = json_decode($row['metval'], true);
if (!empty($cfg['api_key'])) {
$available[] = $provider;
}
}
return $available;
}
/**
* Return the first configured application-level provider (by priority), or null.
*/
public static function getDefaultAppProvider(): ?string
{
$available = self::getAppProviders();
foreach (self::$priority as $p) {
if (in_array($p, $available)) return $p;
}
return null;
}
// ─── Org-level (sp_orgs_meta) ────────────────────────────────────────────
/**
* Return a configured provider instance from sp_orgs_meta (org-level override).
*
* @throws \Exception if provider unknown or not configured for the org
*/
public static function forOrg(int $orgId, string $provider): LLMProvider
{
$provider = strtolower($provider);
if (!isset(self::$providers[$provider])) {
throw new \Exception("Unknown LLM provider: '$provider'");
}
$config = self::getOrgConfig($orgId, $provider);
if (empty($config)) {
throw new \Exception("No '$provider' config found for org $orgId");
}
$class = self::$providers[$provider];
return new $class($config);
}
/**
* Read and decode a provider config from sp_orgs_meta for the given org.
*/
public static function getOrgConfig(int $orgId, string $provider): array
{
$provider = strtolower($provider);
$metaKey = self::$metaKeys[$provider] ?? null;
if (!$metaKey) return [];
$row = \Db::getRow(
"SELECT metval FROM sp_orgs_meta WHERE orgid = ? AND keyval = ? AND active = 1 LIMIT 1",
[$orgId, $metaKey]
);
if (!$row || empty($row['metval'])) return [];
$config = json_decode($row['metval'], true);
return is_array($config) ? $config : [];
}
/**
* Return which providers are configured for the given org (sp_orgs_meta only).
*/
public static function getAvailableProviders(int $orgId): array
{
$metaKeys = array_values(self::$metaKeys);
$rows = \Db::select(
"SELECT keyval FROM sp_orgs_meta WHERE orgid = ? AND keyval IN ('" . implode("','", $metaKeys) . "') AND active = 1",
[$orgId]
);
$flip = array_flip(self::$metaKeys);
$available = [];
foreach ($rows as $row) {
if (isset($flip[$row['keyval']])) {
$available[] = $flip[$row['keyval']];
}
}
return $available;
}
/**
* Save or update a provider config for an org in sp_orgs_meta.
*/
public static function saveOrgConfig(int $orgId, string $provider, array $config): void
{
$provider = strtolower($provider);
$metaKey = self::$metaKeys[$provider] ?? null;
if (!$metaKey) {
throw new \Exception("Unknown LLM provider: '$provider'");
}
$existing = \Db::getRow(
"SELECT orgmetaid FROM sp_orgs_meta WHERE orgid = ? AND keyval = ? LIMIT 1",
[$orgId, $metaKey]
);
if ($existing) {
\Db::update('sp_orgs_meta',
['metval' => json_encode($config)],
'orgmetaid = ?',
[$existing['orgmetaid']]
);
} else {
\Db::insert('sp_orgs_meta', [
'orgid' => $orgId,
'keyval' => $metaKey,
'metval' => json_encode($config),
'active' => 1,
]);
}
}
}

191
api/app/LLM/LLMProvider.php Normal file
View file

@ -0,0 +1,191 @@
<?php
namespace App\LLM;
/**
* Abstract base class for all LLM provider services.
* Every svc*.php must extend this and implement all abstract methods.
*/
abstract class LLMProvider
{
protected string $apiKey = '';
protected string $model = '';
protected string $systemPrompt = '';
protected int $maxTokens = 4096;
protected float $temperature = 0.7;
protected int $timeout = 120;
/**
* Send a prompt and return the full response.
*
* @param array $messages [['role' => 'user', 'content' => '...']]
* @param array $options Override defaults (model, max_tokens, temperature, etc.)
* @return array ['success' => bool, 'content' => string, 'usage' => array, 'error' => string]
*/
abstract public function sendPrompt(array $messages, array $options = []): array;
/**
* Stream a prompt response chunk by chunk.
*
* @param array $messages
* @param array $options
* @param callable $callback Called with each chunk: function(string $chunk)
* @return array ['success' => bool, 'error' => string]
*/
abstract public function streamPrompt(array $messages, array $options, callable $callback): array;
/**
* Return available models for this provider.
*
* @return array [['id' => '...', 'label' => '...']]
*/
abstract public function getModels(): array;
/**
* Test the API key is valid and the provider is reachable.
*
* @return array ['success' => bool, 'latency_ms' => int, 'error' => string]
*/
abstract public function testConnection(): array;
// ─── Fluent Setters ──────────────────────────────────────────────────────
public function getModel(): string
{
return $this->model;
}
public function setModel(string $model): static
{
$this->model = $model;
return $this;
}
public function setMaxTokens(int $tokens): static
{
$this->maxTokens = $tokens;
return $this;
}
public function setSystemPrompt(string $prompt): static
{
$this->systemPrompt = $prompt;
return $this;
}
public function setTemperature(float $temp): static
{
$this->temperature = $temp;
return $this;
}
public function setTimeout(int $seconds): static
{
$this->timeout = $seconds;
return $this;
}
// ─── Shared Helpers ──────────────────────────────────────────────────────
/**
* Build a standard error response.
*/
protected function errorResponse(string $message): array
{
return ['success' => false, 'content' => '', 'usage' => [], 'error' => $message];
}
/**
* Build a standard success response.
*/
protected function successResponse(string $content, array $usage = []): array
{
return ['success' => true, 'content' => $content, 'usage' => $usage, 'error' => ''];
}
/**
* Execute a cURL request and return the decoded JSON response.
*/
protected function curlPost(string $url, array $headers, array $payload): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $this->timeout,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($error) {
throw new \Exception("cURL error: $error");
}
$decoded = json_decode($body, true);
if ($code >= 400) {
$msg = $decoded['error']['message'] ?? $decoded['message'] ?? "HTTP $code error";
throw new \Exception($msg);
}
return $decoded;
}
/**
* Execute a streaming cURL request, calling $callback for each SSE data chunk.
*/
protected function curlStream(string $url, array $headers, array $payload, callable $callback): void
{
$payload['stream'] = true;
$errorBody = '';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => false,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_WRITEFUNCTION => function($ch, $data) use ($callback, &$errorBody) {
$lines = explode("\n", $data);
$hasSseData = false;
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'data: ')) {
$hasSseData = true;
$json = substr($line, 6);
if ($json === '[DONE]') break;
$chunk = json_decode($json, true);
if ($chunk) $callback($chunk);
}
}
// If no SSE data lines were found this chunk may be an error body
if (!$hasSseData) {
$errorBody .= $data;
}
return strlen($data);
},
]);
curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($curlError) {
throw new \Exception("cURL stream error: $curlError");
}
if ($httpCode >= 400) {
$decoded = json_decode(trim($errorBody), true);
$msg = $decoded['error']['message'] ?? $decoded['message'] ?? "API error (HTTP $httpCode)";
throw new \Exception($msg);
}
}
}

11
api/app/LLM/OpenAIsvc.php Normal file
View file

@ -0,0 +1,11 @@
<?php
namespace App\LLM;
use Exception;
class OpenAIsvc {
public function __construct($apiKey = null) {
}
}

View file

@ -0,0 +1,181 @@
<?php
namespace App\LLM;
/**
* Anthropic (Claude) provider implementation.
*
* Config keys (from sp_orgs_meta metval JSON):
* secretKey required Anthropic API key (sk-ant-...)
* model optional default: claude-sonnet-4-6
* max_tokens optional default: 4096
* temperature optional default: 0.7
*
* Usage:
* $llm = LLMManager::forOrg($orgId, 'anthropic');
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
* echo $res['content'];
*/
class svcAnthropic extends LLMProvider
{
private const API_BASE = 'https://api.anthropic.com/v1';
private const API_CHAT = self::API_BASE . '/messages';
private const API_VERSION = '2023-06-01';
private static array $availableModels = [
['id' => 'claude-opus-4-6', 'label' => 'Claude Opus 4.6'],
['id' => 'claude-sonnet-4-6', 'label' => 'Claude Sonnet 4.6'],
['id' => 'claude-haiku-4-5-20251001', 'label' => 'Claude Haiku 4.5'],
];
public function __construct(array $config)
{
if (empty($config['secretKey'])) {
throw new \Exception('Anthropic secretKey is required');
}
$this->apiKey = $config['secretKey'];
$this->model = $config['model'] ?? 'claude-sonnet-4-6';
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
$this->temperature = (float)($config['temperature'] ?? 0.7);
}
/**
* Send a chat prompt and return the full response.
*/
public function sendPrompt(array $messages, array $options = []): array
{
try {
$payload = $this->buildPayload($messages, $options);
$response = $this->curlPost(self::API_CHAT, $this->headers(!empty($options['cache_system'])), $payload);
$content = $response['content'][0]['text'] ?? '';
$usage = $response['usage'] ?? [];
return $this->successResponse($content, [
'prompt_tokens' => $usage['input_tokens'] ?? 0,
'completion_tokens' => $usage['output_tokens'] ?? 0,
'total_tokens' => ($usage['input_tokens'] ?? 0) + ($usage['output_tokens'] ?? 0),
]);
} catch (\Exception $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* Stream a chat prompt, calling $callback with each text chunk.
*
* @param callable $callback function(string $chunk) called with each partial text
*/
public function streamPrompt(array $messages, array $options, callable $callback): array
{
try {
$payload = $this->buildPayload($messages, $options);
$streamError = null;
$this->curlStream(self::API_CHAT, $this->headers(!empty($options['cache_system'])), $payload, function(array $chunk) use ($callback, &$streamError) {
$type = $chunk['type'] ?? '';
if ($type === 'content_block_delta') {
$delta = $chunk['delta']['text'] ?? '';
if ($delta !== '') {
$callback($delta);
}
} elseif ($type === 'error') {
// Anthropic sends inline error events during streaming
$streamError = $chunk['error']['message'] ?? 'Anthropic streaming error';
}
});
if ($streamError) {
return ['success' => false, 'error' => $streamError];
}
return ['success' => true, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Return supported Claude models.
*/
public function getModels(): array
{
return self::$availableModels;
}
/**
* Validate the API key with a minimal single-token request.
*/
public function testConnection(): array
{
try {
$start = microtime(true);
$payload = [
'model' => $this->model,
'max_tokens' => 1,
'messages' => [['role' => 'user', 'content' => 'hi']],
];
$this->curlPost(self::API_CHAT, $this->headers(), $payload);
$latency = (int)((microtime(true) - $start) * 1000);
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
}
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private function headers(bool $withCache = false): array
{
$h = [
'Content-Type: application/json',
'x-api-key: ' . $this->apiKey,
'anthropic-version: ' . self::API_VERSION,
];
if ($withCache) {
$h[] = 'anthropic-beta: prompt-caching-2024-07-31';
}
return $h;
}
private function buildPayload(array $messages, array $options): array
{
$model = $options['model'] ?? $this->model;
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
$temperature = (float)($options['temperature'] ?? $this->temperature);
$cacheSystem = !empty($options['cache_system']);
$payload = [
'model' => $model,
'max_tokens' => $maxTokens,
'temperature' => $temperature,
'messages' => $messages,
];
// Anthropic uses a top-level 'system' key.
// When cache_system is set, use block format with cache_control so
// the large static report payload is cached between turns.
if ($this->systemPrompt !== '') {
if ($cacheSystem) {
$payload['system'] = [[
'type' => 'text',
'text' => $this->systemPrompt,
'cache_control' => ['type' => 'ephemeral'],
]];
} else {
$payload['system'] = $this->systemPrompt;
}
}
return $payload;
}
}

177
api/app/LLM/svcDeepSeek.php Normal file
View file

@ -0,0 +1,177 @@
<?php
namespace App\LLM;
/**
* DeepSeek provider implementation.
*
* DeepSeek is fully OpenAI API-compatible, so the structure mirrors
* svcOpenAI with DeepSeek-specific endpoints and models.
*
* Config keys (from sp_orgs_meta metval JSON):
* secretKey required DeepSeek API key
* model optional default: deepseek-chat
* max_tokens optional default: 4096
* temperature optional default: 0.7
*
* Usage:
* $llm = LLMManager::forOrg($orgId, 'deepseek');
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
* echo $res['content'];
*/
class svcDeepSeek extends LLMProvider
{
private const API_BASE = 'https://api.deepseek.com/v1';
private const API_CHAT = self::API_BASE . '/chat/completions';
private const API_MODELS = self::API_BASE . '/models';
private static array $availableModels = [
['id' => 'deepseek-chat', 'label' => 'DeepSeek Chat (V3)'],
['id' => 'deepseek-reasoner', 'label' => 'DeepSeek Reasoner (R1)'],
];
public function __construct(array $config)
{
if (empty($config['secretKey'])) {
throw new \Exception('DeepSeek secretKey is required');
}
$this->apiKey = $config['secretKey'];
$this->model = $config['model'] ?? 'deepseek-chat';
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
$this->temperature = (float)($config['temperature'] ?? 0.7);
}
/**
* Send a chat prompt and return the full response.
*/
public function sendPrompt(array $messages, array $options = []): array
{
try {
$payload = $this->buildPayload($messages, $options);
$response = $this->curlPost(self::API_CHAT, $this->headers(), $payload);
$content = $response['choices'][0]['message']['content'] ?? '';
$usage = $response['usage'] ?? [];
// DeepSeek Reasoner includes reasoning_content separately
$reasoning = $response['choices'][0]['message']['reasoning_content'] ?? '';
return $this->successResponse($content, [
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
'completion_tokens' => $usage['completion_tokens'] ?? 0,
'total_tokens' => $usage['total_tokens'] ?? 0,
'reasoning_tokens' => $usage['completion_tokens_details']['reasoning_tokens'] ?? 0,
'reasoning_content' => $reasoning,
]);
} catch (\Exception $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* Stream a chat prompt, calling $callback with each text chunk.
*
* @param callable $callback function(string $chunk)
*/
public function streamPrompt(array $messages, array $options, callable $callback): array
{
try {
$payload = $this->buildPayload($messages, $options);
$this->curlStream(self::API_CHAT, $this->headers(), $payload, function(array $chunk) use ($callback) {
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
if ($delta !== '') {
$callback($delta);
}
});
return ['success' => true, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Return supported DeepSeek models.
*/
public function getModels(): array
{
return self::$availableModels;
}
/**
* Validate the API key via the models endpoint.
*/
public function testConnection(): array
{
try {
$start = microtime(true);
$ch = curl_init(self::API_MODELS);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $this->headers(),
CURLOPT_TIMEOUT => $this->timeout,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$latency = (int)((microtime(true) - $start) * 1000);
if ($error) throw new \Exception("cURL error: $error");
$decoded = json_decode($body, true);
if ($code !== 200) {
$msg = $decoded['error']['message'] ?? "HTTP $code";
throw new \Exception($msg);
}
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
}
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private function headers(): array
{
return [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
];
}
private function buildPayload(array $messages, array $options): array
{
$model = $options['model'] ?? $this->model;
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
$temperature = (float)($options['temperature'] ?? $this->temperature);
if ($this->systemPrompt !== '') {
array_unshift($messages, ['role' => 'system', 'content' => $this->systemPrompt]);
}
$payload = [
'model' => $model,
'messages' => $messages,
'max_tokens' => $maxTokens,
'temperature' => $temperature,
];
// deepseek-reasoner does not support temperature
if ($model === 'deepseek-reasoner') {
unset($payload['temperature']);
}
return $payload;
}
}

172
api/app/LLM/svcGemini.php Normal file
View file

@ -0,0 +1,172 @@
<?php
namespace App\LLM;
/**
* Google Gemini provider implementation.
*
* Config keys (from sp_orgs_meta metval JSON):
* secretKey required Google AI API key
* model optional default: gemini-2.5-flash
* max_tokens optional default: 4096
* temperature optional default: 0.7
*
* Note: Gemini uses a different API structure from OpenAI-compatible providers.
* Messages use 'parts' arrays and roles are 'user' / 'model' (not 'assistant').
*
* Usage:
* $llm = LLMManager::forOrg($orgId, 'gemini');
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
* echo $res['content'];
*/
class svcGemini extends LLMProvider
{
private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
private const API_CHAT = ':generateContent';
private const API_STREAM = ':streamGenerateContent';
private static array $availableModels = [
['id' => 'gemini-2.5-flash', 'label' => 'Gemini 2.5 Flash'],
['id' => 'gemini-2.5-flash-lite', 'label' => 'Gemini 2.5 Flash Lite'],
['id' => 'gemini-2.0-flash', 'label' => 'Gemini 2.0 Flash'],
['id' => 'gemini-1.5-pro', 'label' => 'Gemini 1.5 Pro'],
['id' => 'gemini-1.5-flash', 'label' => 'Gemini 1.5 Flash'],
];
public function __construct(array $config)
{
if (empty($config['secretKey'])) {
throw new \Exception('Gemini secretKey is required');
}
$this->apiKey = $config['secretKey'];
$this->model = $config['model'] ?? 'gemini-2.5-flash';
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
$this->temperature = (float)($config['temperature'] ?? 0.7);
}
/**
* Send a chat prompt and return the full response.
*/
public function sendPrompt(array $messages, array $options = []): array
{
try {
$model = $options['model'] ?? $this->model;
$url = self::API_BASE . '/' . $model . self::API_CHAT . '?key=' . $this->apiKey;
$payload = $this->buildPayload($messages, $options);
$response = $this->curlPost($url, $this->headers(), $payload);
$content = $response['candidates'][0]['content']['parts'][0]['text'] ?? '';
$usage = $response['usageMetadata'] ?? [];
return $this->successResponse($content, [
'prompt_tokens' => $usage['promptTokenCount'] ?? 0,
'completion_tokens' => $usage['candidatesTokenCount'] ?? 0,
'total_tokens' => $usage['totalTokenCount'] ?? 0,
]);
} catch (\Exception $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* Stream a chat prompt, calling $callback with each text chunk.
*
* @param callable $callback function(string $chunk)
*/
public function streamPrompt(array $messages, array $options, callable $callback): array
{
try {
$model = $options['model'] ?? $this->model;
$url = self::API_BASE . '/' . $model . self::API_STREAM . '?key=' . $this->apiKey . '&alt=sse';
$payload = $this->buildPayload($messages, $options);
$this->curlStream($url, $this->headers(), $payload, function(array $chunk) use ($callback) {
$text = $chunk['candidates'][0]['content']['parts'][0]['text'] ?? '';
if ($text !== '') {
$callback($text);
}
});
return ['success' => true, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Return supported Gemini models.
*/
public function getModels(): array
{
return self::$availableModels;
}
/**
* Validate the API key with a minimal request.
*/
public function testConnection(): array
{
try {
$start = microtime(true);
$model = $this->model;
$url = self::API_BASE . '/' . $model . self::API_CHAT . '?key=' . $this->apiKey;
$payload = [
'contents' => [['role' => 'user', 'parts' => [['text' => 'hi']]]],
'generationConfig' => ['maxOutputTokens' => 1],
];
$this->curlPost($url, $this->headers(), $payload);
$latency = (int)((microtime(true) - $start) * 1000);
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
}
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private function headers(): array
{
return ['Content-Type: application/json'];
}
private function buildPayload(array $messages, array $options): array
{
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
$temperature = (float)($options['temperature'] ?? $this->temperature);
// Convert OpenAI-style messages to Gemini format
// Roles: 'user' stays 'user', 'assistant' becomes 'model'
$contents = [];
foreach ($messages as $msg) {
$contents[] = [
'role' => $msg['role'] === 'assistant' ? 'model' : 'user',
'parts' => [['text' => $msg['content']]],
];
}
$payload = [
'contents' => $contents,
'generationConfig' => [
'maxOutputTokens' => $maxTokens,
'temperature' => $temperature,
],
];
// Gemini uses a top-level 'systemInstruction' for system prompts
if ($this->systemPrompt !== '') {
$payload['systemInstruction'] = [
'parts' => [['text' => $this->systemPrompt]],
];
}
return $payload;
}
}

169
api/app/LLM/svcMistral.php Normal file
View file

@ -0,0 +1,169 @@
<?php
namespace App\LLM;
/**
* Mistral AI provider implementation.
*
* Mistral uses an OpenAI-compatible API structure, making it
* the most straightforward integration after OpenAI itself.
*
* Config keys (from sp_orgs_meta metval JSON):
* secretKey required Mistral API key
* model optional default: mistral-large-latest
* max_tokens optional default: 4096
* temperature optional default: 0.7
*
* Usage:
* $llm = LLMManager::forOrg($orgId, 'mistral');
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
* echo $res['content'];
*/
class svcMistral extends LLMProvider
{
private const API_BASE = 'https://api.mistral.ai/v1';
private const API_CHAT = self::API_BASE . '/chat/completions';
private const API_MODELS = self::API_BASE . '/models';
private static array $availableModels = [
['id' => 'mistral-large-latest', 'label' => 'Mistral Large'],
['id' => 'mistral-medium-latest', 'label' => 'Mistral Medium'],
['id' => 'mistral-small-latest', 'label' => 'Mistral Small'],
['id' => 'codestral-latest', 'label' => 'Codestral'],
['id' => 'open-mistral-nemo', 'label' => 'Mistral Nemo'],
['id' => 'open-mixtral-8x22b', 'label' => 'Mixtral 8x22B'],
];
public function __construct(array $config)
{
if (empty($config['secretKey'])) {
throw new \Exception('Mistral secretKey is required');
}
$this->apiKey = $config['secretKey'];
$this->model = $config['model'] ?? 'mistral-large-latest';
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
$this->temperature = (float)($config['temperature'] ?? 0.7);
}
/**
* Send a chat prompt and return the full response.
*/
public function sendPrompt(array $messages, array $options = []): array
{
try {
$payload = $this->buildPayload($messages, $options);
$response = $this->curlPost(self::API_CHAT, $this->headers(), $payload);
$content = $response['choices'][0]['message']['content'] ?? '';
$usage = $response['usage'] ?? [];
return $this->successResponse($content, [
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
'completion_tokens' => $usage['completion_tokens'] ?? 0,
'total_tokens' => $usage['total_tokens'] ?? 0,
]);
} catch (\Exception $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* Stream a chat prompt, calling $callback with each text chunk.
*
* @param callable $callback function(string $chunk)
*/
public function streamPrompt(array $messages, array $options, callable $callback): array
{
try {
$payload = $this->buildPayload($messages, $options);
$this->curlStream(self::API_CHAT, $this->headers(), $payload, function(array $chunk) use ($callback) {
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
if ($delta !== '') {
$callback($delta);
}
});
return ['success' => true, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Return supported Mistral models.
*/
public function getModels(): array
{
return self::$availableModels;
}
/**
* Validate the API key via the models endpoint.
*/
public function testConnection(): array
{
try {
$start = microtime(true);
$ch = curl_init(self::API_MODELS);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $this->headers(),
CURLOPT_TIMEOUT => $this->timeout,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$latency = (int)((microtime(true) - $start) * 1000);
if ($error) throw new \Exception("cURL error: $error");
$decoded = json_decode($body, true);
if ($code !== 200) {
$msg = $decoded['message'] ?? "HTTP $code";
throw new \Exception($msg);
}
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
}
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private function headers(): array
{
return [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
];
}
private function buildPayload(array $messages, array $options): array
{
$model = $options['model'] ?? $this->model;
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
$temperature = (float)($options['temperature'] ?? $this->temperature);
if ($this->systemPrompt !== '') {
array_unshift($messages, ['role' => 'system', 'content' => $this->systemPrompt]);
}
return [
'model' => $model,
'messages' => $messages,
'max_tokens' => $maxTokens,
'temperature' => $temperature,
];
}
}

175
api/app/LLM/svcOpenAI.php Normal file
View file

@ -0,0 +1,175 @@
<?php
namespace App\LLM;
/**
* OpenAI provider implementation.
*
* Config keys (from sp_orgs_meta metval JSON):
* secretKey required OpenAI API key (sk-...)
* model optional default: gpt-4o
* max_tokens optional default: 4096
* temperature— optional default: 0.7
*
* Usage:
* $llm = LLMManager::forOrg($orgId, 'openai');
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
* echo $res['content'];
*/
class svcOpenAI extends LLMProvider
{
private const API_BASE = 'https://api.openai.com/v1';
private const API_CHAT = self::API_BASE . '/chat/completions';
private const API_MODELS = self::API_BASE . '/models';
private static array $availableModels = [
['id' => 'gpt-4o', 'label' => 'GPT-4o'],
['id' => 'gpt-4o-mini', 'label' => 'GPT-4o Mini'],
['id' => 'gpt-4-turbo', 'label' => 'GPT-4 Turbo'],
['id' => 'gpt-4', 'label' => 'GPT-4'],
['id' => 'gpt-3.5-turbo', 'label' => 'GPT-3.5 Turbo'],
['id' => 'o1', 'label' => 'o1'],
['id' => 'o1-mini', 'label' => 'o1 Mini'],
['id' => 'o3-mini', 'label' => 'o3 Mini'],
];
public function __construct(array $config)
{
if (empty($config['secretKey'])) {
throw new \Exception('OpenAI secretKey is required');
}
$this->apiKey = $config['secretKey'];
$this->model = $config['model'] ?? 'gpt-4o';
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
$this->temperature = (float)($config['temperature'] ?? 0.7);
}
/**
* Send a chat prompt and return the full response.
*/
public function sendPrompt(array $messages, array $options = []): array
{
try {
$payload = $this->buildPayload($messages, $options);
$response = $this->curlPost(self::API_CHAT, $this->headers(), $payload);
$content = $response['choices'][0]['message']['content'] ?? '';
$usage = $response['usage'] ?? [];
return $this->successResponse($content, [
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
'completion_tokens' => $usage['completion_tokens'] ?? 0,
'total_tokens' => $usage['total_tokens'] ?? 0,
]);
} catch (\Exception $e) {
return $this->errorResponse($e->getMessage());
}
}
/**
* Stream a chat prompt, calling $callback with each text chunk.
*
* @param callable $callback function(string $chunk) called with each partial text
*/
public function streamPrompt(array $messages, array $options, callable $callback): array
{
try {
$payload = $this->buildPayload($messages, $options);
$this->curlStream(self::API_CHAT, $this->headers(), $payload, function(array $chunk) use ($callback) {
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
if ($delta !== '') {
$callback($delta);
}
});
return ['success' => true, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Return the static list of supported OpenAI models.
*/
public function getModels(): array
{
return self::$availableModels;
}
/**
* Validate the API key by calling the models endpoint.
*/
public function testConnection(): array
{
try {
$start = microtime(true);
$ch = curl_init(self::API_MODELS);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $this->headers(),
CURLOPT_TIMEOUT => $this->timeout,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$latency = (int)((microtime(true) - $start) * 1000);
if ($error) throw new \Exception("cURL error: $error");
$decoded = json_decode($body, true);
if ($code !== 200) {
$msg = $decoded['error']['message'] ?? "HTTP $code";
throw new \Exception($msg);
}
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
} catch (\Exception $e) {
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
}
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private function headers(): array
{
return [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey,
];
}
private function buildPayload(array $messages, array $options): array
{
$model = $options['model'] ?? $this->model;
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
$temperature = (float)($options['temperature'] ?? $this->temperature);
// Prepend system prompt if set
if ($this->systemPrompt !== '') {
array_unshift($messages, ['role' => 'system', 'content' => $this->systemPrompt]);
}
$payload = [
'model' => $model,
'messages' => $messages,
'max_tokens' => $maxTokens,
];
// o1/o3 models don't support temperature
if (!str_starts_with($model, 'o1') && !str_starts_with($model, 'o3')) {
$payload['temperature'] = $temperature;
}
return $payload;
}
}

View file

@ -0,0 +1,55 @@
<?php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class GreetCommand extends Command
{
protected $commandName = 'app:greet';
protected $commandDescription = "Greets Someone";
protected $commandArgumentName = "name";
protected $commandArgumentDescription = "Who do you want to greet?";
protected $commandOptionName = "cap"; // should be specified like "app:greet John --cap"
protected $commandOptionDescription = 'If set, it will greet in uppercase letters';
protected function configure()
{
$this
->setName($this->commandName)
->setDescription($this->commandDescription)
->addArgument(
$this->commandArgumentName,
InputArgument::OPTIONAL,
$this->commandArgumentDescription
)
->addOption(
$this->commandOptionName,
null,
InputOption::VALUE_NONE,
$this->commandOptionDescription
)
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$name = $input->getArgument($this->commandArgumentName);
if ($name) {
$text = 'Hello '.$name;
} else {
$text = 'Hello';
}
if ($input->getOption($this->commandOptionName)) {
$text = strtoupper($text);
}
$output->writeln($text);
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,134 @@
<?php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SeedMenuCommand extends Command
{
protected $commandName = 'SeedMenu';
protected $commandDescription = 'Create sp_menus + sp_menu_items tables and seed admin sidebar data';
protected function configure()
{
$this->setName($this->commandName)
->setDescription($this->commandDescription);
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$output->writeln('<info>Creating tables...</info>');
Db::execute("
CREATE TABLE IF NOT EXISTS sp_menus (
menu_id int unsigned NOT NULL AUTO_INCREMENT,
slug varchar(50) NOT NULL,
name varchar(100) NOT NULL,
active tinyint DEFAULT 1,
PRIMARY KEY (menu_id),
UNIQUE KEY (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
", []);
Db::execute("
CREATE TABLE IF NOT EXISTS sp_menu_items (
item_id int unsigned NOT NULL AUTO_INCREMENT,
menu_id int unsigned NOT NULL,
parent_id int unsigned DEFAULT NULL,
label varchar(100) NOT NULL,
icon varchar(100) DEFAULT NULL,
type enum('link','header','divider') DEFAULT 'link',
url varchar(255) DEFAULT NULL,
perm_id int unsigned DEFAULT NULL,
match_prefix tinyint DEFAULT 0,
sort_order int DEFAULT 0,
active tinyint DEFAULT 1,
PRIMARY KEY (item_id),
KEY (menu_id),
KEY (parent_id),
CONSTRAINT FOREIGN KEY (perm_id) REFERENCES sp_permissions(perm_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
", []);
$output->writeln('<info>Tables ready. Seeding data...</info>');
// Idempotent: skip if already seeded
$existing = Db::getRow("SELECT menu_id FROM sp_menus WHERE slug = 'admin_sidebar' LIMIT 1");
if ($existing) {
$output->writeln('<comment>admin_sidebar already exists — skipping seed. Run with --force to re-seed (delete rows manually first).</comment>');
return Command::SUCCESS;
}
// ── Insert menu ──────────────────────────────────────────────────────────
$menuResult = json_decode(Db::insert('sp_menus', [
'slug' => 'admin_sidebar',
'name' => 'Admin Sidebar',
'active' => 1,
]), true);
$menuId = (int)$menuResult['ID'];
$output->writeln("Menu ID: {$menuId}");
// Helper: fetch perm_id by controller.action, return null if not found
$perm = function(string $controller, string $action) use ($output): ?int {
$row = Db::getRow(
"SELECT perm_id FROM sp_permissions WHERE perm_controller = ? AND perm_action = ? LIMIT 1",
[$controller, $action]
);
if (!$row) {
$output->writeln("<comment> Permission not found: {$controller}.{$action} — using NULL</comment>");
return null;
}
return (int)$row['perm_id'];
};
// Helper: insert item, return item_id
$insertItem = function(array $data) use ($menuId): int {
$data['menu_id'] = $menuId;
$result = json_decode(Db::insert('sp_menu_items', $data), true);
return (int)$result['ID'];
};
$sort = 10;
// ── Parent: Manage Account ───────────────────────────────────────────────
$acctId = $insertItem([
'parent_id' => null,
'label' => 'Manage Account',
'icon' => 'ki-duotone ki-address-book',
'type' => 'link',
'url' => null,
'perm_id' => null,
'sort_order' => $sort += 10,
]);
$subSort = 0;
$insertItem(['parent_id' => $acctId, 'label' => 'Account Settings', 'type' => 'link', 'url' => '/account', 'perm_id' => null, 'sort_order' => $subSort += 10]);
$insertItem(['parent_id' => $acctId, 'label' => 'Billing', 'type' => 'link', 'url' => '/account/billing', 'perm_id' => null, 'sort_order' => $subSort += 10]);
$insertItem(['parent_id' => $acctId, 'label' => 'Invoices', 'type' => 'link', 'url' => '/account/invoices', 'perm_id' => null, 'sort_order' => $subSort += 10]);
$insertItem(['parent_id' => $acctId, 'label' => 'Integrations & API', 'type' => 'link', 'url' => '/account/integrations', 'perm_id' => null, 'sort_order' => $subSort += 10]);
$insertItem(['parent_id' => $acctId, 'label' => 'Activity Logs', 'type' => 'link', 'url' => '/account/activity', 'perm_id' => null, 'sort_order' => $subSort += 10]);
// ── Parent: Administrator ────────────────────────────────────────────────
$adminId = $insertItem([
'parent_id' => null,
'label' => 'Administrator',
'icon' => 'ki-duotone ki-shield-tick',
'type' => 'link',
'url' => null,
'perm_id' => null,
'sort_order' => $sort += 10,
]);
$subSort = 0;
$insertItem(['parent_id' => $adminId, 'label' => 'Overview', 'type' => 'link', 'url' => '/admin', 'perm_id' => $perm('admin', 'index'), 'sort_order' => $subSort += 10, 'match_prefix' => 0]);
$insertItem(['parent_id' => $adminId, 'label' => 'Manage Orgs', 'type' => 'link', 'url' => '/admin/orgs', 'perm_id' => $perm('admin', 'orgs'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
$insertItem(['parent_id' => $adminId, 'label' => 'Manage Users', 'type' => 'link', 'url' => '/admin/users', 'perm_id' => $perm('admin', 'users'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
$insertItem(['parent_id' => $adminId, 'label' => 'Integrations', 'type' => 'link', 'url' => '/admin/integrations', 'perm_id' => $perm('admin', 'integrations'), 'sort_order' => $subSort += 10]);
$insertItem(['parent_id' => $adminId, 'label' => 'User Roles', 'type' => 'link', 'url' => '/admin/roles', 'perm_id' => $perm('admin', 'roles'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
$insertItem(['parent_id' => $adminId, 'label' => 'Activity Logs', 'type' => 'link', 'url' => '/admin/activity', 'perm_id' => $perm('admin', 'activity'), 'sort_order' => $subSort += 10]);
$insertItem(['parent_id' => $adminId, 'label' => 'Menu Builder', 'type' => 'link', 'url' => '/admin/menu', 'perm_id' => $perm('admin', 'menu'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
$output->writeln('<info>Seeded successfully.</info>');
return Command::SUCCESS;
}
}

119
api/commands/commands.md Normal file
View file

@ -0,0 +1,119 @@
# CLI Commands — `commands/`
Scripts run via `php console <CommandName>`. No HTTP context — output goes to stdout.
## File Naming
`PascalCaseCommand.php` — e.g. `SyncDataCommand.php`
## Running Commands
```bash
php console SyncDataCommand
php console SyncDataCommand --date="2024-10-28" --force
php console CleanupDatabaseCommand --days=30 --dry-run
php console ProcessEmailQueueCommand --limit=50
```
## Command Structure
```php
<?php
// commands/SyncDataCommand.php
echo "=== Data Sync ===\n";
$options = getopt("", ["date:", "force"]);
$date = $options['date'] ?? 'today';
$force = isset($options['force']);
echo "Syncing for: $date\n";
try {
$records = Db::select("SELECT * FROM sp_sync_queue WHERE synced_at IS NULL");
foreach ($records as $record) {
if (!$force) {
$exists = Db::getRow("SELECT 1 FROM sp_data WHERE external_id = :id LIMIT 1",
[':id' => $record['external_id']]
);
if ($exists) { echo "Skipping {$record['external_id']}\n"; continue; }
}
Db::insert('sp_data', [
'external_id' => $record['external_id'],
'payload' => $record['payload'],
'synced_at' => date('Y-m-d H:i:s')
]);
echo "Inserted {$record['external_id']}\n";
}
echo "Done.\n";
} catch (Exception $e) {
echo "ERROR: " . $e->getMessage() . "\n";
exit(1);
}
```
## Error Handling Pattern
Log individual failures and keep processing — exit with code 1 only on fatal errors:
```php
$errors = [];
$processed = 0;
foreach ($items as $item) {
try {
processItem($item);
$processed++;
echo ".";
} catch (Exception $e) {
$errors[] = ['id' => $item['id'], 'error' => $e->getMessage()];
echo "E";
}
}
echo "\n\nProcessed: $processed | Errors: " . count($errors) . "\n";
if (!empty($errors)) {
foreach ($errors as $err) {
echo "Item #{$err['id']}: {$err['error']}\n";
}
exit(1);
}
```
## Dry-Run Pattern
```php
$dryRun = isset($options['dry-run']);
if ($dryRun) { echo "DRY RUN — no changes will be made\n"; }
$count = Db::getRow("SELECT COUNT(*) as n FROM sp_logs WHERE created_at < :date",
[':date' => $cutoffDate]
);
echo "Rows to delete: {$count['n']}\n";
if (!$dryRun) {
Db::delete('sp_logs', 'created_at < :date', [':date' => $cutoffDate]);
}
```
## Cron Setup
```bash
crontab -e
```
```cron
# Every 5 minutes
*/5 * * * * cd /www/wwwroot/appSeedProject && php console ProcessEmailQueueCommand >> /var/log/email-queue.log 2>&1
# Every hour
0 * * * * cd /www/wwwroot/appSeedProject && php console SyncDataCommand >> /var/log/sync.log 2>&1
# Nightly cleanup at 2 AM
0 2 * * * cd /www/wwwroot/appSeedProject && php console CleanupDatabaseCommand --days=30 >> /var/log/cleanup.log 2>&1
```

49
api/composer.json Normal file
View file

@ -0,0 +1,49 @@
{
"name": "seedproject/seedproject",
"require": {
"anthropic-ai/sdk": "^0.3.0",
"carbonphp/carbon-doctrine-types": "^2.1.0",
"getbrevo/brevo-php": "^1.0.2",
"guzzlehttp/promises": "^2.3.0",
"guzzlehttp/psr7": "^2.8.0",
"mashape/unirest-php": "^3.0.4",
"nyholm/psr7": "^1.8.2",
"php-http/discovery": "^1.20.0",
"php-http/multipart-stream-builder": "^1.4.2",
"phpmailer/phpmailer": "^6.12.0",
"psr/container": "^2.0.2",
"psr/http-client": "^1.0.3",
"psr/http-factory": "^1.1.0",
"psr/http-message": "^2.0",
"symfony/console": "^5.4.47",
"symfony/deprecation-contracts": "^3.6.0",
"symfony/polyfill-ctype": "^1.33.0",
"symfony/polyfill-intl-grapheme": "^1.33.0",
"symfony/polyfill-intl-normalizer": "^1.33.0",
"symfony/polyfill-mbstring": "^1.33.0",
"symfony/polyfill-php73": "^1.33.0",
"symfony/polyfill-php80": "^1.33.0",
"symfony/service-contracts": "^3.6.1",
"symfony/string": "^6.4.30"
},
"autoload": {
"classmap": [
"core/",
"app/Helpers",
"app/Controllers",
"app/Components",
"app/Gateways",
"system/"
],
"psr-4": {
"App\\": "app",
"Models\\": "models"
}
},
"config": {
"platform-check": false,
"allow-plugins": {
"php-http/discovery": true
}
}
}

1970
api/composer.lock generated Normal file

File diff suppressed because it is too large Load diff

17
api/console Normal file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env php
<?php
# Documentation https://symfony.com/doc/current/console.html
# Everytime you added a new command you need to run: composer dump -o
# Usage: php console app:ticker ltcusdt
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\Console\Application;
$application = new Application();
# add our commands
$application->add(new GreetCommand());
//$application->add(new Sentinel());
//$application->add(new Engine());
$application->run();

253
api/core/AltoRouter.php Normal file
View file

@ -0,0 +1,253 @@
<?php
class AltoRouter {
protected $routes = array();
protected $namedRoutes = array();
protected $basePath = '';
protected $matchTypes = array(
'i' => '[0-9]++',
'a' => '[0-9A-Za-z]++',
'h' => '[0-9A-Fa-f]++',
'*' => '.+?',
'**' => '.++',
'' => '[^/\.]++'
);
/**
* Create router in one call from config.
*
* @param array $routes
* @param string $basePath
* @param array $matchTypes
*/
public function __construct($routes = array(), $basePath = '', $matchTypes = array()) {
$this->setBasePath($basePath);
$this->addMatchTypes($matchTypes);
foreach ($routes as $route) {
call_user_func_array(array($this, 'map'), $route);
}
}
/**
* Set the base path.
* Useful if you are running your application from a subdirectory.
*/
public function setBasePath($basePath) {
$this->basePath = $basePath;
}
/**
* Add named match types. It uses array_merge so keys can be overwritten.
*
* @param array $matchTypes The key is the name and the value is the regex.
*/
public function addMatchTypes($matchTypes) {
$this->matchTypes = array_merge($this->matchTypes, $matchTypes);
}
/**
* Map a route to a target
*
* @param string $method One of 4 HTTP Methods, or a pipe-separated list of multiple HTTP Methods (GET|POST|PUT|DELETE)
* @param string $route The route regex, custom regex must start with an @. You can use multiple pre-set regex filters, like [i:id]
* @param mixed $target The target where this route should point to. Can be anything.
* @param string $name Optional name of this route. Supply if you want to reverse route this url in your application.
*
*/
public function map($method, $route, $target, $name = null) {
$this->routes[] = array($method, $route, $target, $name);
if ($name) {
if (isset($this->namedRoutes[$name])) {
throw new \Exception("Can not redeclare route '{$name}'");
} else {
$this->namedRoutes[$name] = $route;
}
}
return;
}
/**
* Reversed routing
*
* Generate the URL for a named route. Replace regexes with supplied parameters
*
* @param string $routeName The name of the route.
* @param array @params Associative array of parameters to replace placeholders with.
* @return string The URL of the route with named parameters in place.
*/
public function generate($routeName, array $params = array()) {
// Check if named route exists
if (!isset($this->namedRoutes[$routeName])) {
throw new \Exception("Route '{$routeName}' does not exist.");
}
// Replace named parameters
$route = $this->namedRoutes[$routeName];
// prepend base path to route url again
$url = $this->basePath . $route;
if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
list($block, $pre, $type, $param, $optional) = $match;
if ($pre) {
$block = substr($block, 1);
}
if (isset($params[$param])) {
$url = str_replace($block, $params[$param], $url);
} elseif ($optional) {
$url = str_replace($pre . $block, '', $url);
}
}
}
return $url;
}
/**
* Match a given Request Url against stored routes
* @param string $requestUrl
* @param string $requestMethod
* @return array|boolean Array with route information on success, false on failure (no match).
*/
public function match($requestUrl = null, $requestMethod = null) {
$params = array();
$match = false;
// set Request Url if it isn't passed as parameter
if ($requestUrl === null) {
$requestUrl = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
}
// strip base path from request url
$requestUrl = substr($requestUrl, strlen($this->basePath));
// Strip query string (?a=b) from Request Url
if (($strpos = strpos($requestUrl, '?')) !== false) {
$requestUrl = substr($requestUrl, 0, $strpos);
}
// set Request Method if it isn't passed as a parameter
if ($requestMethod === null) {
$requestMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
}
// Force request_order to be GP
// http://www.mail-archive.com/internals@lists.php.net/msg33119.html
$_REQUEST = array_merge($_GET, $_POST);
foreach ($this->routes as $handler) {
list($method, $_route, $target, $name) = $handler;
$methods = explode('|', $method);
$method_match = false;
// Check if request method matches. If not, abandon early. (CHEAP)
foreach ($methods as $method) {
if (strcasecmp($requestMethod, $method) === 0) {
$method_match = true;
break;
}
}
// Method did not match, continue to next route.
if (!$method_match)
continue;
// Check for a wildcard (matches all)
if ($_route === '*') {
$match = true;
} elseif (isset($_route[0]) && $_route[0] === '@') {
$match = preg_match('`' . substr($_route, 1) . '`', $requestUrl, $params);
} else {
$route = null;
$regex = false;
$j = 0;
$n = isset($_route[0]) ? $_route[0] : null;
$i = 0;
// Find the longest non-regex substring and match it against the URI
while (true) {
if (!isset($_route[$i])) {
break;
} elseif (false === $regex) {
$c = $n;
$regex = $c === '[' || $c === '(' || $c === '.';
if (false === $regex && false !== isset($_route[$i + 1])) {
$n = $_route[$i + 1];
$regex = $n === '?' || $n === '+' || $n === '*' || $n === '{';
}
if (false === $regex && $c !== '/' && (!isset($requestUrl[$j]) || $c !== $requestUrl[$j])) {
continue 2;
}
$j++;
}
$route .= $_route[$i++];
}
$regex = $this->compileRoute($route);
$match = preg_match($regex, $requestUrl, $params);
}
if (($match == true || $match > 0)) {
if ($params) {
foreach ($params as $key => $value) {
if (is_numeric($key))
unset($params[$key]);
}
}
return array(
'target' => $target,
'params' => $params,
'name' => $name
);
}
}
return false;
}
/**
* Compile the regex for a given route (EXPENSIVE)
*/
private function compileRoute($route) {
if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) {
$matchTypes = $this->matchTypes;
foreach ($matches as $match) {
list($block, $pre, $type, $param, $optional) = $match;
if (isset($matchTypes[$type])) {
$type = $matchTypes[$type];
}
if ($pre === '.') {
$pre = '\.';
}
//Older versions of PCRE require the 'P' in (?P<named>)
$pattern = '(?:'
. ($pre !== '' ? $pre : null)
. '('
. ($param !== '' ? "?P<$param>" : null)
. $type
. '))'
. ($optional !== '' ? '?' : null);
$route = str_replace($block, $pattern, $route);
}
}
return "`^$route$`";
}
}

248
api/core/Bootstrap.php Normal file
View file

@ -0,0 +1,248 @@
<?php
require_once __DIR__ . '/../system/ErrorHandler.php';
class Bootstrap {
private $_url = null;
private $_controller = null;
private $_controllerPath = 'controllers/'; // Always include trailing slash
private $_modelPath = 'models/'; // Always include trailing slash
private $_errorFile = 'error.php';
private $_defaultFile = 'index.php';
private $_defaultPath = 'public';
/**
* Starts the Bootstrap
*
* @return boolean
*/
public function __construct(){
set_error_handler([ErrorHandler::class, 'handleError']);
set_exception_handler([ErrorHandler::class, 'handleException']);
register_shutdown_function([ErrorHandler::class, 'handleShutdown']);
// Turn off display_errors if you want to handle all error display through this mechanism
ini_set('display_errors', 'Off');
}
public function init() {
// Sets the protected $_url
$this->_getUrl();
// Load the default controller if no URL is set
// eg: Visit http://localhost it loads Default Controller
if (empty($this->_url[0])) {
$this->_loadDefaultController();
return false;
}
//Router
$this->match = \Router::Routing();
if(DEBUG == true) {
register_shutdown_function(function () {
$err = error_get_last();
if (! is_null($err)) {
print 'Error#'.$err['message'].'<br>';
print 'Line#'.$err['line'].'<br>';
print 'File#'.$err['file'].'<br>';
}
});
}
// This check whether there is a match
if (empty($this->match)) {
$this->_loadExistingController();
$this->_callControllerMethod();
} else {
$this->_loadRouter();
}
//$this->_loadExistingController();
//$this->_callControllerMethod();
}
/**
* (Optional) Set a custom path to controllers
* @param string $path
*/
public function setControllerPath($path ='') {
\Helper::print_array($path);
$this->_controllerPath = trim($path, '') . '';
}
/**
* (Optional) Set a custom path to models
* @param string $path
*/
public function setModelPath($path ='') {
$this->_modelPath = trim($path, '') . '';
}
/**
* (Optional) Set a custom path to the error file
* @param string $path Use the file name of your controller, eg: error.php
*/
public function setErrorFile($path ='') {
$this->_errorFile = trim($path, '/');
}
/**
* (Optional) Set a custom path to the error file
* @param string $path Use the file name of your controller, eg: index.php
*/
public function setDefaultFile($path = '') {
$this->_defaultFile = trim($path, '/');
}
/**
* (Optional) Set a custom path to the error file
* @param string $path Use the file name of your controller, eg: index.php
*/
public function setDefaultPath($path = '') {
$this->_defaultPath = trim($path, ''); // Removed the trim for /
}
/**
* Fetches the $_GET from 'url'
*/
private function _getUrl() {
$url = isset($_GET['url']) ? $_GET['url'] : null;
$url = rtrim($url, '/');
$url = filter_var($url, FILTER_SANITIZE_URL);
$this->_url = explode('/', $url);
}
/**
* This loads if there is no GET parameter passed
*/
private function _loadDefaultController() {
require $this->_defaultPath . '/' . $this->_controllerPath . $this->_defaultFile;
$this->_controller = new Index();
$this->_controller->index();
}
/**
* Load an existing controller if there IS a GET parameter passed
*
* @return boolean|string
*/
private function _loadExistingController() {
$file = $this->_defaultPath . '/' . $this->_controllerPath . $this->_url[0] . '.php';
if (file_exists($file)) {
require $file;
if($this->_url[1]) { $method = $this->_url[1]; } else { $method = "index"; }
$this->_controller = new $this->_url[0]($method);
$this->_controller->loadModel($this->_url[0], $this->_modelPath);
} else {
$this->_error();
return false;
}
}
/**
* Loads Router if there's a rule set for specific URL combinate
* @return boolean
*/
private function _loadRouter() {
/// Run the Router
$this->ControllerName = $this->match['target']['c'];
$this->MethodName = $this->match['target']['a'];
$this->URIParameters = $this->match['params'];
$file = $this->_defaultPath . '/' . $this->_controllerPath . $this->ControllerName . '.php';
if (file_exists($file)) {
require $file;
$this->_controller = new $this->ControllerName($this->MethodName);
$this->_controller->loadModel($this->ControllerName, $this->_modelPath);
} else {
$this->_error();
return false;
}
// Load Controller
$this->_controller->{$this->MethodName}($this->URIParameters);
}
/**
* If a method is passed in the GET url parameter
*
* http://localhost/controller/method/(param)/(param)/(param)
* url[0] = Controller
* url[1] = Method (falls back to 'index' if not a valid method)
* url[2] = Param
* url[3] = Param
* url[4] = Param
*
* Fallback: /controller/param controller->index(param)
*/
private function _callControllerMethod() {
$length = count($this->_url);
// If url[1] is not a valid method, treat it as a param to index()
if ($length > 1) {
if (!method_exists($this->_controller, $this->_url[1])) {
array_splice($this->_url, 1, 0, ['index']);
$length = count($this->_url);
}
}
// Determine what to load
switch ($length) {
case 5:
//Controller->Method(Param1, Param2, Param3)
$this->_controller->{$this->_url[1]}($this->_url[2], $this->_url[3], $this->_url[4]);
break;
case 4:
//Controller->Method(Param1, Param2)
$this->_controller->{$this->_url[1]}($this->_url[2], $this->_url[3]);
break;
case 3:
//Controller->Method(Param1, Param2)
$this->_controller->{$this->_url[1]}($this->_url[2]);
break;
case 2:
//Controller->Method(Param1, Param2)
$this->_controller->{$this->_url[1]}();
break;
default:
$this->_controller->index();
break;
}
}
/**
* Display an error page if nothing exists
*
* @return boolean
*/
private function _error() {
require dirname(__DIR__) . '/' . $this->_defaultPath . '/' . $this->_controllerPath . $this->_errorFile;
$this->_controller = new _Error();
$this->_controller->index();
exit;
}
}

29
api/core/Controller.php Normal file
View file

@ -0,0 +1,29 @@
<?php
class Controller {
function __construct() {
$this->view = new View();
$this->view->_c = $this;
}
/**
*
* @param string $name Name of the model
* @param string $path Location of the models
*/
public function loadModel($name, $modelPath = 'models/') {
$path = $modelPath . $name.'_model.php';
if (file_exists($path)) {
require $modelPath .$name.'_model.php';
$modelName = $name . '_Model';
$this->model = new $modelName();
$this->view->_m = $this->model; // extends [model] access to views
}
}
}

321
api/core/Database.php Normal file
View file

@ -0,0 +1,321 @@
<?php
class Database extends PDO
{
public function __construct($DB_TYPE, $DB_HOST, $DB_NAME, $DB_USER, $DB_PASS)
{
parent::__construct($DB_TYPE.':host='.$DB_HOST.';dbname='.$DB_NAME.';charset=utf8' , $DB_USER, $DB_PASS);
parent::setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_LAZY); // Fetch_lazy - handles (num, assoc & object) without memory overhead
parent::setAttribute( PDO::ATTR_EMULATE_PREPARES, true );
parent::setAttribute(PDO::MYSQL_ATTR_INIT_COMMAND, "SET NAME'utf8'");
parent::setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
parent::setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}
/**
* select
* @param string $sql An SQL string
* @param array $array Paramters to bind
* @param constant $fetchMode A PDO Fetch mode
* @return mixed
*/
public function select($sql, $array = array(), $fetchMode = PDO::FETCH_ASSOC)
{
try {
$sth = $this->prepare($sql);
foreach ($array as $key => $value) {
$sth->bindValue("$key", $value);
}
$sth->execute();
return $sth->fetchAll($fetchMode);
} catch (PDOException $e) {
$Response['Code']='0';
$Response['Message']= 'Error Select Entry: ' . $e->getMessage();
echo json_encode($Response);
return false;
}
}
/**
* insert
* @param string $table A name of table to insert into
* @param string $data An associative array
*/
public function insert($table, $data)
{
ksort($data);
$fieldNames = implode('`, `', array_keys($data));
$fieldValues = ':' . implode(', :', array_keys($data));
try {
$sth = $this->prepare("INSERT INTO $table (`$fieldNames`) VALUES ($fieldValues)");
foreach ($data as $key => $value) {
$sth->bindValue(":$key", $value);
}
$sth->execute();
$msg = $sth->errorInfo();
if(!$msg[1]) { $msgCode = 1; } else { $msgCode = $msg[1]; }
if(!$msg[2]) { $msgMessage = 'Created'; } else { $msgMessage = $msg[2]; }
$Response['Code']=$msgCode;
$Response['Message']=$msgMessage;
$Response['ID']= $this->lastInsertId();
} catch (PDOException $e) {
$Response['Code']='0';
$Response['Message']= 'Error Creating Entry: ' . $e->getMessage();
}
return json_encode($Response);
}
/**
* update
* @param string $table A name of table to insert into
* @param string $data An associative array
* @param string $where the WHERE query part
*/
public function update($table, $data, $where)
{
ksort($data);
$fieldDetails = NULL;
foreach($data as $key=> $value) {
$fieldDetails .= "`$key`=:$key,";
}
$fieldDetails = rtrim($fieldDetails, ',');
try {
$sth = $this->prepare("UPDATE $table SET $fieldDetails WHERE $where");
foreach ($data as $key => $value) {
$sth->bindValue(":$key", $value);
}
$sth->execute();
$count = $sth->rowCount();
if($count){
$Response['Code']='1';
$Response['Rows']= $count;
$Response['Message']='Updated';
} else {
$Response['Code']='0';
$Response['Rows']= $count;
$Response['Message']='No Records Updated';
}
} catch (PDOException $e) {
$Response['Code']='0';
$Response['Message']= 'Error Updating Database: ' . $e->getMessage();
}
return json_encode($Response);
}
/**
* delete
*
* @param string $table
* @param string $where
* @param integer $limit
* @return integer Affected Rows
*/
public function delete($table, $where, $limit = 1)
{
$sth = $this->prepare("DELETE FROM $table WHERE $where LIMIT $limit");
return $sth->execute();
}
/**
* Query
*
* @param string $sql
* @return returns array results
*/
public function execQuery($sql, $limit='') {
// echo $sql;
// print_array($limit);
if($limit) {
$LimitStart = $limit['start'];
$LimitEnd = $limit['end'];
}
$sth = $this->prepare($sql);
if($LimitStart) $sth->bindParam(1, $LimitStart,PDO::PARAM_INT);
if($LimitEnd) $sth->bindParam(2, $LimitEnd,PDO::PARAM_INT);
$sth->execute();
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Query
*
* @param string $table
* @param array $arrQuery
* @return returns array results
* $
*/
public function wherein($table, $column, $arrQuery, $CustomSQL='') {
$SQLWhereIn = implode(',', $arrQuery);
if($CustomSQL) {
$CustomSQL = " AND " . $CustomSQL;
}
$sql = "SELECT SQL_CALC_FOUND_ROWS * FROM {$table} WHERE {$column} IN ({$SQLWhereIn}) $CustomSQL";
// print_array($sql);
$sth = $this->prepare($sql);
$sth->execute();
$count = $this->prepare('SELECT FOUND_ROWS() as Rows');
$count->execute();
$Counted = $count->fetchAll(PDO::FETCH_ASSOC);
$Counted = $Counted[0]['Rows'];
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
$Result['rowsfound'] = $Counted;
return $Result;
}
/**
* select
* @param string $sql An SQL string
* @param array $array Paramters to bind
* @param constant $fetchMode A PDO Fetch mode
* @return mixed
public function retrieve($table, $sql, $fetchMode = PDO::FETCH_ASSOC)
{
$sql = "SELECT * FROM business_profile WHERE 1 LIMIT 0, 100";
print_array($sql);
$sth = $this->prepare($sql);
$sth->execute();
$statement = $this->query('SELECT FOUND_ROWS() AS CountedRows');
print_array($statement);
$Counted = $Counted[0]['CountedRows'];
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
$Result['rowsfound'] = $Counted;
return $Result;
// return $sth->fetchAll($fetchMode);
}
*/
/**
* select
* @param string $sql An SQL string
* @param array $array Paramters to bind
* @param constant $fetchMode A PDO Fetch mode
* @return mixed
*/
public function retrieve($sql, $array = array(), $pagination = '')
{
try {
$sth = $this->prepare($sql);
foreach ($array as $key => $value) {
$sth->bindValue("$key", $value);
}
$sth->execute();
$statement = $this->execQuery('SELECT FOUND_ROWS() AS CountedRows');
$Counted = $statement[0]['CountedRows'];
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
$Result['rowsfound'] = $Counted;
return $Result;
// return $sth->fetchAll($fetchMode);
} catch (PDOException $e) {
$Result['Code']='0';
$Result['Message']= 'Error Select Entry: ' . $e->getMessage();
return json_encode($Result);
}
}
public function pagination($sql, $page='0', $limit='10')
{
try {
$find = array("select * from", "LIMIT ?,?");
$NewSQL = str_ireplace($find, '', $sql);
$totalSQL = "SELECT count(*) as Counted FROM {$NewSQL}";
$statement = $this->execQuery($totalSQL);
$Counted = $statement[0]['Counted'];
if(!$page) { $page = '0'; }
if(!$limit) { $limit = 10; }
$TotalPages = floor($Counted / $limit);
if($page) { $page_first_result = ($page) * $limit; } else { $page_first_result = '0'; }
$sth = $this->prepare($sql);
$sth->execute([$page_first_result, $limit]);
// echo "{$page_first_result}, $limit";
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
$Result['total'] = $Counted;
$Result['limit'] = $limit;
$Result['page'] = $page;
$Result['pages'] = $TotalPages;
return $Result;
} catch (PDOException $e) {
$Result['Code']='0';
$Result['Message']= 'Error Select Entry: ' . $e->getMessage();
return json_encode($Result);
}
}
}

76
api/core/ErrorHandler.php Normal file
View file

@ -0,0 +1,76 @@
<?php
class ErrorHandler {
public static function handleError($errno, $errstr, $errfile, $errline) {
if (!(error_reporting() & $errno)) {
// This error code is not included in error_reporting
return;
}
self::logError($errno, $errstr, $errfile, $errline);
self::displayError($errno, $errstr, $errfile, $errline);
// Don't execute PHP internal error handler
return true;
}
public static function handleException($exception) {
self::logError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine());
self::displayError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine(), $exception->getTraceAsString());
}
public static function handleShutdown() {
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
self::logError($error['type'], $error['message'], $error['file'], $error['line']);
self::displayError($error['type'], $error['message'], $error['file'], $error['line']);
}
}
private static function logError($errno, $errstr, $errfile, $errline) {
$message = date('[Y-m-d H:i:s]') . " Error: [$errno] $errstr in $errfile on line $errline\n";
error_log($message, 3, 'app_errors.log');
}
private static function displayError($errno, $errstr, $errfile, $errline, $trace = null) {
$errorTypes = [
E_ERROR => 'Fatal Error',
E_WARNING => 'Warning',
E_PARSE => 'Parse Error',
E_NOTICE => 'Notice',
E_CORE_ERROR => 'Core Error',
E_CORE_WARNING => 'Core Warning',
E_COMPILE_ERROR => 'Compile Error',
E_COMPILE_WARNING => 'Compile Warning',
E_USER_ERROR => 'User Error',
E_USER_WARNING => 'User Warning',
E_USER_NOTICE => 'User Notice',
E_STRICT => 'Strict Standards',
E_RECOVERABLE_ERROR => 'Recoverable Error',
E_DEPRECATED => 'Deprecated',
E_USER_DEPRECATED => 'User Deprecated',
];
$errorType = isset($errorTypes[$errno]) ? $errorTypes[$errno] : 'Unknown Error';
if (DEBUG) {
echo "<div style='background-color: #f8d7da; color: #721c24; padding: 10px; margin: 10px; border: 1px solid #f5c6cb; border-radius: 5px;'>";
echo "<h1 style='color: #721c24;'>$errorType Occurred</h1>";
echo "<p><strong>Message:</strong> $errstr</p>";
echo "<p><strong>File:</strong> $errfile</p>";
echo "<p><strong>Line:</strong> $errline</p>";
if ($trace) {
echo "<h2>Stack Trace:</h2>";
echo "<pre>$trace</pre>";
}
echo "<h2>Request Details:</h2>";
echo "<pre>";
echo "URL: " . $_SERVER['REQUEST_URI'] . "\n";
echo "Method: " . $_SERVER['REQUEST_METHOD'] . "\n";
echo "Time: " . date('Y-m-d H:i:s') . "\n";
echo "IP: " . $_SERVER['REMOTE_ADDR'] . "\n";
echo "</pre>";
echo "</div>";
}
}
}

14
api/core/Model.php Normal file
View file

@ -0,0 +1,14 @@
<?php
class Model {
function __construct() {
$this->db = new Database(DB_TYPE, DB_HOST, DB_NAME, DB_USER, DB_PASS);
$this->db->setAttribute( PDO::ATTR_EMULATE_PREPARES, false );
//$this->remotedb = new Database(DB_TYPE_REMOTE, DB_HOST_REMOTE, DB_NAME_REMOTE, DB_USER_REMOTE, DB_PASS_REMOTE);
//$this->remotedb->setAttribute( PDO::ATTR_EMULATE_PREPARES, false );
}
}

65
api/core/Session.php Normal file
View file

@ -0,0 +1,65 @@
<?php
class Session
{
public static function init()
{
@session_start();
}
public static function set($key, $value)
{
$_SESSION[$key] = $value;
}
public static function get($key)
{
if (isset($_SESSION[$key]))
return $_SESSION[$key];
}
public static function destroy()
{
//unset($_SESSION);
session_destroy();
}
/*
* Set a value or create a child under parent session.
*/
public static function pSet($parent, $key, $value)
{
$_SESSION[$parent][$key] = $value;
}
/*
* Unset Just Child
*/
public static function cUnset($parent, $key)
{
unset($_SESSION[$parent][$key]);
}
/*
* Unset Parent and everything under it
*/
public static function pUnset($key)
{
unset($_SESSION[$key]);
}
/**
*
* @param parameter $key
* @return Session
*/
public static function pGet($parent, $key)
{
if (isset($_SESSION[$parent][$key]))
return $_SESSION[$parent][$key];
}
}

51
api/core/View.php Normal file
View file

@ -0,0 +1,51 @@
<?php
class View {
function __construct() {
//echo 'this is the view';
$this->path = VIEWS_PATH . '/views/';
}
public function render($name, $type='site', $noInclude = true) {
if(empty($type)) { $type = 'site'; }
$name = strtolower($name);
extract((array) $this);
if ($noInclude == false) {
require $this->path . "" . $name . ".php";
} else {
include $this->path . "wrapper/{$type}/header.php";
require $this->path . $name . ".php";
include $this->path . "wrapper/{$type}/footer.php";
}
}
/**
* generates partial view. Good for straight JSON or XML responses for internal views.
* @param $name
* @return string
*/
function PartialView($name, $param=false)
{
extract((array) $this);
ob_start();
include ($this->path . "partial/" . $name . ".php");
$name = ob_get_clean();
return ($name);
ob_end_flush();
}
function js($data){
print_array($data);
}
/*
public function render($name) {
require 'views/' . $name . '.php';
}
*/
}

128
api/core/core.md Normal file
View file

@ -0,0 +1,128 @@
# Core Framework — `core/`
Framework internals. **Rarely modified.** Contains the request lifecycle engine.
## Files
| File | Purpose |
|------|---------|
| `Bootstrap.php` | Bootstraps environment, loads config, starts routing |
| `Controller.php` | Base controller class — all UI controllers extend this (via `AppController`) |
| `View.php` | View renderer — handles `render()`, `PartialView()`, asset injection |
| `AltoRouter.php` | URL router used for complex custom routes |
---
## Request Lifecycle
```
Request
→ public/index.php
→ config.php (loads .env, defines constants)
→ core/Bootstrap.php (environment setup, session, autoload)
→ AltoRouter / auto-routing
→ ControllerClass::method($param)
→ $this->view->render(...)
→ Response (HTML or JSON)
```
Auto-routing maps `/controller/method/param` directly to `ControllerClass::method($param)`.
---
## `Controller` (`core/Controller.php`)
Base class. Provides:
- `$this->view` — View instance
- `$this->JavaScript[]` — array of JS paths to inject
- `$this->Styles[]` — array of CSS paths to inject
- `redirect($url)` — HTTP redirect helper
**Do not modify this file.** App-wide customisation belongs in `app/Controllers/AppController.php`.
---
## `AppController` (`app/Controllers/AppController.php`)
Sits between `core/Controller` and all UI page controllers.
UI controllers extend `AppController`, not `Controller` directly.
**Current responsibilities:**
- Injects `$UserProfile` (logged-in user's full profile via `User::profile()`) into every view automatically.
**`use` statement scoping rule:**
- Namespaces needed on every page → `AppController`
- Namespaces needed by one controller → that controller only
- Namespaces needed by one method → inline as fully-qualified class name
```php
// app/Controllers/AppController.php
use App\Components\User;
class AppController extends Controller {
function __construct() {
parent::__construct();
if (!empty($_SESSION['login']['userid'])) {
$this->view->UserProfile = User::profile($_SESSION['login']['userid']);
}
}
}
```
---
## `View` (`core/View.php`)
**Properties:**
- Any property set on `$this->view` becomes a variable in the rendered template.
**Methods:**
- `render($viewPath, $wrapper = '', $useWelcome = true)` — render a view
- `PartialView($partialName)` — load and return a partial as a string
**Render signatures used in practice:**
```php
$this->view->render(__CLASS__ . '/' . __FUNCTION__); // default admin wrapper
$this->view->render(__CLASS__ . '/' . __FUNCTION__, 'login'); // login wrapper
$this->view->render('clients/index', true, 'site'); // legacy style
```
---
## Routing
### Auto-Routing (default)
| URL | Maps to |
|-----|---------|
| `/clients` | `Clients::index()` |
| `/clients/show/123` | `Clients::show(123)` |
| `/products/edit/456` | `Products::edit(456)` |
### AltoRouter (complex routes)
Define in `public/routes.php` or `api/routes.php`:
```php
// Named route with typed parameter
$router->map('GET', '/users/[i:id]', 'Users#show', 'user_show');
// Multiple parameters
$router->map('GET', '/blog/[i:year]/[i:month]/[*:slug]', 'Blog#show', 'blog_post');
// POST
$router->map('POST', '/api/users/create', 'UsersAPI#create', 'api_user_create');
```
**Match type tokens:**
- `[i:id]` — integer
- `[a:action]` — alphanumeric (A-Z, a-z, 0-9, -)
- `[h:key]` — hex
- `[*:trailing]` — catch-all (no slashes)
- `[**:path]` — catch-all including slashes
```php
// Generate URL from named route
echo $router->generate('user_show', ['id' => 5]); // → /users/5
```

6
api/index.php Normal file
View file

@ -0,0 +1,6 @@
<?php
require_once realpath(__DIR__) . '/public/index.php';
// Security
// var_dump(is_dir($_SERVER['DOCUMENT_ROOT'] . '/install'));

11
api/install/.htaccess Normal file
View file

@ -0,0 +1,11 @@
ErrorDocument 500 /error
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^(.+)$ index.php?url=$1 [QSA,L]

File diff suppressed because one or more lines are too long

10775
api/install/assets/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
$("#checkDB").click(function() {
var dbloca = $('#dblocal').val();
var dbuser = $('#dbuser').val();
var dbpass = $('#dbpass').val();
var dbname = $('#dbname').val();
// $(this).parents('.AccessoryItem').fadeOut('fast');
$.ajax({
cache: false,
type: 'POST',
url: '/install/index/checkdb',
data: {
"dbloca" : dbloca,
"dbuser" : dbuser,
"dbpass" : dbpass,
"dbname" : dbname,
},
success: function(data)
{
if(data == 1) {
$("#dbstatus").slideDown("fast", function () {
$('#dbstatus').html('Database Connection Successful.');
$('#dbstatus').removeClass('alert-danger').addClass('alert-success');
});
} else {
$("#dbstatus").slideDown("fast", function () {
$('#dbstatus').html('Failed to Connect to Database. Check your settings and try again.');
$('#dbstatus').removeClass('alert-success').addClass('alert-danger');
});
}
}
});
return false;
});

2
api/install/assets/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,25 @@
<?php
class _Error extends Controller {
function __construct() {
parent::__construct();
}
function index(){
$this->Styles['commenta'] = '<!-- Css files -->';
$this->view->Styles = $this->Styles;
$this->JavaScript[] = ASSETS . "/js/pages/crypto-dashboard.init.js";
$this->view->JavaScript = $this->JavaScript;
$this->view->title = COMPANY;
$this->view->render(__CLASS__ .'/'. __FUNCTION__);
}
} // end class

View file

@ -0,0 +1,35 @@
<?php
class I extends Controller {
function __construct() {
parent::__construct();
$this->Styles['commenta'] = '<!-- Css files -->';
$this->view->Styles = $this->Styles;
$this->JavaScript[] = "/install/assets/jquery.min.js";
$this->JavaScript[] = "/install/assets/custom.js";
$this->view->JavaScript = $this->JavaScript;
}
function index() {
die();
}
function requirements(){
$this->view->render('index/requirements');
}
function setup(){
$this->view->render('index/setup');
}
function complete(){
$this->view->render('index/complete');
}
}

View file

@ -0,0 +1,139 @@
<?php
class Index extends Controller {
function __construct() {
parent::__construct();
}
function index() {
$this->view->render(__CLASS__ .'/'. __FUNCTION__);
}
function checkDB(){
// \Helper::print_array($_POST);
$host = $_POST['dbloca'];
$user = $_POST['dbuser'];
$pass = $_POST['dbpass'];
$db = $_POST['dbname'];
$charset = 'utf8';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_LAZY,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
echo 1;
} catch (\PDOException $e) {
echo 0;
}
}
function installation() {
// \Helper::print_array($_POST);
if(!$_POST) {
header("Location: /install/");
die();
};
$dbLoca = $_POST['dbloca'];
$dbName = $_POST['dbname'];
$dbUser = $_POST['dbuser'];
$dbPass = $_POST['dbpass'];
# Step 1: building out database structure
$SQLFile = $_SERVER['DOCUMENT_ROOT'] . '/install/dump.sql';
$ConfigFile = $_SERVER['DOCUMENT_ROOT'] . '/config.php';
$HashKey = $this->getName(50);
$HashAPIKey = $this->getName(50);
if(!file_exists($SQLFile) ){
die('Error: Failed to Load SQL Dump File. ');
}
$sql = file_get_contents($SQLFile);
$mysqli = new mysqli($dbLoca, $dbUser, $dbPass, $dbName);
/* check connection */
if ($mysqli->connect_errno) {
printf("Connect failed: %s\n", $mysqli->connect_error);
exit();
}
if (!$mysqli->multi_query($sql)) {
printf("Error message: %s\n", $mysqli->error);
};
/* close connection */
$mysqli->close();
# Step 2: Create the Config file.
// if(file_exists($ConfigFile)) { die ('File Currently Exist. Please Delete config.php; if you are trying to do a new install.'); }
$myfile = fopen($ConfigFile, "w") or die("Unable to Write or Open file, Please check your permissions!");
fwrite($myfile, '');
fclose($myfile);
$config_content =
<<<SEED
<?php
define('URL', 'https://www.seedproject.com/');
define('SITE_BASE', '/');
define('ASSETS', '/public/assets/');
define('LIBS', 'core/');
define('COMPANY', 'CryptoBot - by CarlosArias.com');
define('DEBUG', false);
define('EMAILUSER', 'ADDEMAILUSER');
define('EMAILPASSWORD', 'ADDEMAILPASSWORD');
define('EMAILHOST', 'ADDEMAILHOST');
define('DB_TYPE', 'mysql');
define('DB_HOST', '{$dbLoca}');
define('DB_NAME', '{$dbName}');
define('DB_USER', '{$dbUser}');
define('DB_PASS', '{$dbPass}');
define( 'HASH_PASSWORD_KEY', '{$HashKey}');
define( 'HASH_API_KEY', '{$HashAPIKey}');
define( 'TIMESTAMP', date('Y-m-d H:i:s'));
date_default_timezone_set('America/New_York');
\Db::setConnectionInfo(DB_TYPE, DB_NAME, DB_USER, DB_PASS);
SEED;
file_put_contents($ConfigFile, $config_content);
header("Location: /install/i/complete");
}
function getName($n) {
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-=_+<>?;:\/';
$randomString = '';
for ($i = 0; $i < $n; $i++) {
$index = rand(0, strlen($characters) - 1);
$randomString .= $characters[$index];
}
return $randomString;
}
}

346
api/install/dump.sql Normal file
View file

@ -0,0 +1,346 @@
/*
SQLyog Ultimate v12.09 (64 bit)
MySQL - 10.3.35-MariaDB : Database - ochenta80_db123
*********************************************************************
*/
/*!40101 SET NAMES utf8 */;
/*!40101 SET SQL_MODE=''*/;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`ochenta80_db123` /*!40100 DEFAULT CHARACTER SET latin1 */;
/*Table structure for table `api_auth` */
DROP TABLE IF EXISTS `api_auth`;
CREATE TABLE `api_auth` (
`id` int(255) NOT NULL AUTO_INCREMENT,
`userid` int(10) DEFAULT NULL,
`apikey` varchar(255) DEFAULT NULL,
`planid` int(10) DEFAULT NULL,
`premium` int(10) DEFAULT NULL,
`requests` int(10) DEFAULT 1,
`active` int(10) DEFAULT 1,
`created_date` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `UniqueAPI` (`apikey`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
/*Data for the table `api_auth` */
/*Table structure for table `api_plans` */
DROP TABLE IF EXISTS `api_plans`;
CREATE TABLE `api_plans` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`code` varchar(255) DEFAULT '0',
`productcode` varchar(255) DEFAULT NULL,
`productcodeyear` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`description` varchar(255) DEFAULT NULL,
`html` text DEFAULT NULL,
`monthly` decimal(5,2) DEFAULT 0.00,
`yearly` decimal(5,2) DEFAULT 0.00,
`limit_minute` int(1) DEFAULT 0,
`limit_monthly` int(1) DEFAULT 0,
`active` int(1) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=13 DEFAULT CHARSET=latin1;
/*Data for the table `api_plans` */
insert into `api_plans`(`id`,`code`,`productcode`,`productcodeyear`,`name`,`description`,`html`,`monthly`,`yearly`,`limit_minute`,`limit_monthly`,`active`) values (1,'starter','plan_DhbUlvsEfOJKaD','plan_DhbUIjKseU2Y6z','Starter',NULL,NULL,'4.95','50.00',0,60000,1);
/*Table structure for table `api_requests` */
DROP TABLE IF EXISTS `api_requests`;
CREATE TABLE `api_requests` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`requesting_ip` varchar(255) DEFAULT NULL,
`request` varchar(255) DEFAULT NULL,
`service` varchar(255) DEFAULT NULL,
`domainURI` varchar(255) DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
/*Data for the table `api_requests` */
/*Table structure for table `api_usage` */
DROP TABLE IF EXISTS `api_usage`;
CREATE TABLE `api_usage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userid` int(11) DEFAULT 0,
`daily` int(11) DEFAULT 0,
`monthly` int(11) DEFAULT 0,
`yearly` int(11) DEFAULT 0,
`dateof` date DEFAULT '0000-00-00',
`created_date` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
/*Data for the table `api_usage` */
/*Table structure for table `config` */
DROP TABLE IF EXISTS `config`;
CREATE TABLE `config` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`key` varchar(255) DEFAULT NULL,
`value` varchar(255) DEFAULT NULL,
`date_created` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=latin1;
/*Data for the table `config` */
insert into `config`(`id`,`key`,`value`,`date_created`) values (1,'stripe_secret_key','','2014-12-03 12:13:59'),(2,'stripe_publishable_key','','2014-12-03 12:13:59'),(3,'paypal_environment','sandbox','2014-12-11 02:24:45'),(4,'payment_type','input','2014-12-03 12:13:59'),(5,'https_redirect','0','2014-12-03 12:13:59'),(6,'email','','2014-12-03 12:13:59'),(7,'show_description','1','2014-12-03 12:13:59'),(8,'page_title','Stripe Advanced Payment Terminal','2014-12-03 12:13:59'),(9,'show_billing_address','1','2014-12-03 12:13:59'),(10,'name','','2014-12-03 23:49:55'),(11,'enable_paypal','1','2014-12-04 02:22:47'),(12,'enable_subscriptions','stripe_and_paypal','2014-12-04 04:03:15'),(13,'paypal_email','','2014-12-04 05:59:49'),(14,'subscription_length','0','2014-12-08 04:11:49'),(15,'subscription_interval','1','2014-12-08 04:13:06'),(16,'currency','USD','2014-12-29 11:29:16'),(17,'enable_trial','0','2014-12-31 00:48:23'),(18,'trial_days','7','2014-12-31 01:03:34'),(19,'notification_status','check','2014-12-31 00:48:23');
/*Table structure for table `orgcategory` */
DROP TABLE IF EXISTS `orgcategory`;
CREATE TABLE `orgcategory` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT '0',
`active` int(11) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=6 DEFAULT CHARSET=latin1;
/*Data for the table `orgcategory` */
insert into `orgcategory`(`id`,`name`,`active`) values (1,'Agency',1);
/*Table structure for table `orgprofile` */
DROP TABLE IF EXISTS `orgprofile`;
CREATE TABLE `orgprofile` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`catid` int(11) DEFAULT 0,
`name` varchar(255) DEFAULT '0',
`address` varchar(255) DEFAULT '0',
`address2` varchar(255) DEFAULT '0',
`city` varchar(255) DEFAULT '0',
`state` varchar(255) DEFAULT '0',
`zip` int(11) DEFAULT 0,
`phone` varchar(255) DEFAULT '0',
`email` varchar(255) DEFAULT '0',
`logo` varchar(255) DEFAULT '0',
`website` varchar(255) DEFAULT '0',
`json_social_media` text DEFAULT '0',
`summary` text DEFAULT '0',
`description` text DEFAULT '0',
`auto_email` varchar(255) DEFAULT '0',
`updated_date` datetime DEFAULT '0000-00-00 00:00:00',
`taxid` varchar(255) DEFAULT '0',
`payment_mode` varchar(11) DEFAULT '0',
`routingnum` varchar(255) DEFAULT '0',
`bankacctnum` varchar(255) DEFAULT '0',
`bankname` varchar(255) DEFAULT '0',
`nameonbank` varchar(255) DEFAULT '0',
`active` int(1) DEFAULT 1,
`agreement` varchar(100) DEFAULT '0',
`ach_auth` varchar(100) DEFAULT '0',
`createdate` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=109 DEFAULT CHARSET=latin1;
/*Data for the table `orgprofile` */
insert into `orgprofile`(`id`,`catid`,`name`,`address`,`address2`,`city`,`state`,`zip`,`phone`,`email`,`logo`,`website`,`json_social_media`,`summary`,`description`,`auto_email`,`updated_date`,`taxid`,`payment_mode`,`routingnum`,`bankacctnum`,`bankname`,`nameonbank`,`active`,`agreement`,`ach_auth`,`createdate`) values (106,1,'Snoopi','1515 S. Federal Hwl','Suite 2001','Boca Raton','Florida',33426,'3023570198','support@snoopi.io',NULL,NULL,NULL,NULL,NULL,NULL,'0000-00-00 00:00:00',NULL,NULL,NULL,NULL,NULL,NULL,1,NULL,NULL,'2020-08-17 20:06:13');
/*Table structure for table `orguserjoin` */
DROP TABLE IF EXISTS `orguserjoin`;
CREATE TABLE `orguserjoin` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userid` int(11) DEFAULT 0,
`orgid` int(11) DEFAULT 0,
`updated_date` datetime DEFAULT '0000-00-00 00:00:00',
`created_date` datetime DEFAULT current_timestamp(),
`active` int(11) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
/*Data for the table `orguserjoin` */
/*Table structure for table `permissions` */
DROP TABLE IF EXISTS `permissions`;
CREATE TABLE `permissions` (
`perm_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`perm_controller` varchar(50) NOT NULL DEFAULT '0',
`perm_action` varchar(50) NOT NULL DEFAULT '0',
`name` varchar(255) DEFAULT '0',
`viewroleid` varchar(255) DEFAULT '0' COMMENT 'role that can see these options',
`menu` int(11) DEFAULT 0,
`menuorder` int(11) DEFAULT 0,
PRIMARY KEY (`perm_id`)
) ENGINE=InnoDB AUTO_INCREMENT=48 DEFAULT CHARSET=latin1;
/*Data for the table `permissions` */
insert into `permissions`(`perm_id`,`perm_controller`,`perm_action`,`name`,`viewroleid`,`menu`,`menuorder`) values (20,'index','*','Dashboard','1,2,3,4',20,4),(21,'demos','*','Demos','0',21,0),(22,'apikeys','*','API Key','0',22,0),(23,'account','*','Account','0',23,1),(24,'_roles','*','apiRoles','0',1,0),(25,'_subscription','*','apiSubscription','0',0,0),(26,'_users','*','apiUsers','0',0,0),(27,'_test','*','Test','0',0,0),(28,'_dashboard','*','apiDashboard','0',0,0);
/*Table structure for table `role_perm` */
DROP TABLE IF EXISTS `role_perm`;
CREATE TABLE `role_perm` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`role_id` int(10) unsigned NOT NULL DEFAULT 0,
`perm_id` int(10) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `role_id` (`role_id`),
KEY `perm_id` (`perm_id`),
CONSTRAINT `role_perm_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`),
CONSTRAINT `role_perm_ibfk_2` FOREIGN KEY (`perm_id`) REFERENCES `permissions` (`perm_id`)
) ENGINE=InnoDB AUTO_INCREMENT=69 DEFAULT CHARSET=latin1;
/*Data for the table `role_perm` */
insert into `role_perm`(`id`,`role_id`,`perm_id`) values (50,4,20),(51,4,21),(52,4,22),(53,4,23),(54,4,24),(55,4,25),(56,4,26),(57,2,20),(58,2,21),(60,2,22),(61,2,23),(62,2,24),(63,2,25),(64,2,26),(65,2,27),(66,4,27),(67,2,28),(68,4,28);
/*Table structure for table `roles` */
DROP TABLE IF EXISTS `roles`;
CREATE TABLE `roles` (
`role_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`role_name` varchar(50) NOT NULL DEFAULT '0',
`controller` varchar(100) DEFAULT '0',
`createdate` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=latin1;
/*Data for the table `roles` */
insert into `roles`(`role_id`,`role_name`,`controller`,`createdate`) values (1,'System - Administrator','adminz','2020-08-28 17:56:08'),(2,'Freebies','','2020-08-28 17:56:08'),(4,'Premium','','2020-09-05 20:13:21'),(6,'Marketing','marketing','2020-08-28 17:56:08');
/*Table structure for table `roles_menu` */
DROP TABLE IF EXISTS `roles_menu`;
CREATE TABLE `roles_menu` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`roleid` int(11) DEFAULT NULL,
`menu` text DEFAULT NULL,
`created` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
/*Data for the table `roles_menu` */
insert into `roles_menu`(`id`,`roleid`,`menu`,`created`) values (1,4,'{\"Clients\":{\"parent\":{\"name\":\"Clients\",\"link\":\"\\/clients\"}},\"Dashboard\":{\"parent\":{\"name\":\"Dashboard\",\"link\":\"\\/dashboard\"}},\"Companies\":{\"parent\":{\"name\":\"Companies\",\"link\":\"\\/companies\"},\"child\":[{\"name\":\"View Companies\",\"link\":\"\\/companies\"},{\"name\":\"Add New \",\"link\":\"\\/companies\\/add\"}]}}','2020-09-09 16:39:04');
/*Table structure for table `usergen` */
DROP TABLE IF EXISTS `usergen`;
CREATE TABLE `usergen` (
`userid` int(11) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(50) DEFAULT '0',
`password` varchar(64) DEFAULT '0',
`email` varchar(255) DEFAULT '0',
`fname` varchar(255) DEFAULT '0',
`lname` varchar(255) DEFAULT '0',
`phone` varchar(255) DEFAULT '0',
`whatsapp` varchar(255) DEFAULT '0',
`telegram` varchar(255) DEFAULT '0',
`website` varchar(255) DEFAULT '0',
`address1` varchar(255) DEFAULT '0',
`address2` varchar(255) DEFAULT '0',
`city` varchar(255) DEFAULT '0',
`state` varchar(255) DEFAULT '0',
`zip` varchar(255) DEFAULT '0',
`country` varchar(255) DEFAULT '0',
`province` varchar(255) DEFAULT '0',
`vat` varchar(255) DEFAULT '0',
`preferences` text DEFAULT '0',
`avatar` varchar(255) DEFAULT '0',
`cus_token` varchar(255) DEFAULT '0',
`role_id` int(11) unsigned DEFAULT 6,
`isadmin` int(1) DEFAULT 0,
`lastlogin` datetime DEFAULT '0000-00-00 00:00:00',
`created_date` datetime DEFAULT current_timestamp(),
`updated_date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`ipaddress` varchar(255) DEFAULT '000.000.000.000',
`active` int(1) DEFAULT 1,
`notes` varchar(255) DEFAULT '0',
`pwtoken` varchar(20) DEFAULT '0',
PRIMARY KEY (`userid`),
UNIQUE KEY `username` (`username`),
KEY `role_id` (`role_id`),
CONSTRAINT `usergen_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*Data for the table `usergen` */
/*Table structure for table `users_api` */
DROP TABLE IF EXISTS `users_api`;
CREATE TABLE `users_api` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userid` int(11) DEFAULT NULL,
`apikey` varchar(255) DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`active` int(1) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
/*Data for the table `users_api` */
/*Table structure for table `users_notes` */
DROP TABLE IF EXISTS `users_notes`;
CREATE TABLE `users_notes` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userid` int(11) DEFAULT NULL,
`notes` text DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`active` int(1) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
/*Data for the table `users_notes` */
/*Table structure for table `users_plans` */
DROP TABLE IF EXISTS `users_plans`;
CREATE TABLE `users_plans` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userid` int(11) DEFAULT 0,
`planid` int(11) DEFAULT 0,
`premium` int(11) DEFAULT 0,
`stripe_subid` varchar(255) DEFAULT '0',
`created_date` datetime DEFAULT current_timestamp(),
`subcreatedate` datetime DEFAULT '0000-00-00 00:00:00',
`subcanceldate` datetime DEFAULT '0000-00-00 00:00:00',
`promotion` datetime DEFAULT '0000-00-00 00:00:00',
`active` int(11) DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
/*Data for the table `users_plans` */
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

11
api/install/index.php Normal file
View file

@ -0,0 +1,11 @@
<?php
@session_start();
require $_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php';
@require $_SERVER['DOCUMENT_ROOT'] . '/config.php';
define('VIEWS_PATH', __DIR__);
$bootstrap = new Bootstrap();
$bootstrap->setDefaultPath(__DIR__);
$bootstrap->init();

View file

@ -0,0 +1,15 @@
<div class="middle-box text-center animated fadeInDown">
<h1>404</h1>
<h3 class="font-bold">Page Not Found</h3>
<div class="error-desc">
Sorry, but the page you are looking for has note been found. Try checking the URL for error, then hit the refresh button on your browser or try found something else in our app.
<form class="form-inline m-t justify-content-center" role="form">
<div class="form-group">
<input type="text" class="form-control" placeholder="Search for page">
</div>
<button type="submit" class="btn btn-primary">Search</button>
</form>
</div>
</div>

View file

@ -0,0 +1,32 @@
<main>
<section class="container">
<div class="row py-lg-5">
<h2>Installation Complete!</h2>
<p class="text-success">Congratulations! The installation has been successful!</p>
<p>You can now login with the following information:</p>
<table class="table">
<tbody>
<tr>
<th>URL</th>
<td><a href="" id="final_url"></a></td>
</tr>
<tr>
<th>Username</th>
<td>admin</td>
</tr>
<tr>
<th>Password</th>
<td>admin</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>

View file

@ -0,0 +1,19 @@
<main>
<section class="py-5 text-center container">
<div class="row py-lg-5">
<div class="col-lg-6 col-md-8 mx-auto">
<h1 class="fw-light">Installation</h1>
<p class="lead text-muted">Something short and leading about the collection below—its contents, the creator, etc. Make it short and sweet, but not too short so folks dont simply skip over it entirely.</p>
<p>
<a href="/install/i/requirements" class="btn btn-primary my-2">Start Installation</a>
</p>
</div>
</div>
</section>
</main>

View file

@ -0,0 +1,150 @@
<main>
<section class="container">
<div class="row py-lg-5">
<h2>Requirements</h2>
<table class="table mt-3">
<thead class="table-dark">
<th class="bg-gray-200">Prerequisites</th>
<th class="bg-gray-200">Required</th>
<th class="bg-gray-200">Current</th>
<th class="bg-gray-200"></th>
</thead>
<tbody>
<tr>
<td>PHP Version</td>
<td>7.4+</td>
<td><?= PHP_VERSION ?></td>
<td>
<?php if(version_compare(PHP_VERSION, '7.4.0') >= 0): ?>
<i class="bi bi-check2-circle text-success"></i>
<?php else: ?>
<i class="bi bi-x-octagon text-danger"></i>
<? $rFlag = true; ?>
<?php endif ?>
</td>
</tr>
<tr>
<td>cURL</td>
<td>Enabled</td>
<td><?= function_exists('curl_version') ? 'Enabled' : 'Not Enabled' ?></td>
<td>
<?php if(function_exists('curl_version')): ?>
<i class="bi bi-check2-circle text-success"></i>
<?php else: ?>
<i class="bi bi-x-octagon text-danger"></i>
<? $rFlag = true; ?>
<?php endif ?>
</td>
</tr>
<tr>
<td>OpenSSL</td>
<td>Enabled</td>
<td><?= extension_loaded('openssl') ? 'Enabled' : 'Not Enabled' ?></td>
<td>
<?php if(extension_loaded('openssl')): ?>
<i class="bi bi-check2-circle text-success"></i>
<?php else: ?>
<i class="bi bi-x-octagon text-danger"></i>
<? $rFlag = true; ?>
<?php endif ?>
</td>
</tr>
<tr>
<td>mbstring</td>
<td>Enabled</td>
<td><?= extension_loaded('mbstring') && function_exists('mb_get_info') ? 'Enabled' : 'Not Enabled' ?></td>
<td>
<?php if(extension_loaded('mbstring') && function_exists('mb_get_info')): ?>
<i class="bi bi-check2-circle text-success"></i>
<?php else: ?>
<i class="bi bi-x-octagon text-danger"></i>
<? $rFlag = true; ?>
<?php endif ?>
</td>
</tr>
<tr>
<td>PDO</td>
<td>Enabled</td>
<td>
<? if (class_exists('PDO', false)) { echo "Enabled"; } ?>
</td>
<td>
<?php if(function_exists('mysqli_connect')): ?>
<i class="bi bi-check2-circle text-success"></i>
<?php else: ?>
<i class="bi bi-x-octagon text-danger"></i>
<? $rFlag = true; ?>
<?php endif ?>
</td>
</tr>
</tbody>
</table>
<table class="table mt-5">
<thead class="table-dark">
<th class="bg-gray-200">Path / File</th>
<th class="bg-gray-200">Status</th>
<th class="bg-gray-200"></th>
</thead>
<tbody>
<tr>
<td>/config.php</td>
<td><?= is_writable( '../config.php') ? 'Writable' : 'Not Writable';?></td>
<td>
<?php if(is_writable('../config.php')): ?>
<i class="bi bi-check2-circle text-success"></i>
<?php else: ?>
<i class="bi bi-x-octagon text-danger"></i>
<? $rFlag = true; ?>
<?php endif ?>
</td>
</tr>
<tr>
<td>/uploads/</td>
<td><?= is_writable('../uploads/') ? 'Writable' : 'Not Writable' ?></td>
<td>
<?php if(is_writable('../uploads/')): ?>
<i class="bi bi-check2-circle text-success"></i>
<?php else: ?>
<i class="bi bi-x-octagon text-danger"></i>
<? $rFlag = true; ?>
<?php endif ?>
</td>
</tr>
</tbody>
</table>
<div class="mt-3">
<?php if(!$rFlag): ?>
<div class="d-grid gap-2">
<hr>
Everything looks good. Proceed to the next step.
<a href="/install/i/setup" class="btn btn-primary">Next Step</a>
</div>
<?php else: ?>
<div class="alert alert-danger" role="alert">
Please make sure all the requirements listed on the documentation and on this page are met before continuing!
</div>
<p class="text-danger"></p>
<?php endif ?>
</div>
</div>
</section>
</main>

View file

@ -0,0 +1,55 @@
<main>
<section class="container">
<div class="row py-lg-5">
<h2>Setup</h2>
<form action="/install/index/installation" name="setup" method="post">
<div class="mb-3 mt-5">
<label for="exampleFormControlInput1" class="form-label">Email address</label>
<input type="email" name="email" class="form-control" id="exampleFormControlInput1" placeholder="name@example.com">
</div>
<div class="mb-5">
<label for="exampleFormControlInput1" class="form-label">Website Url</label>
<input type="text" name="website" class="form-control" id="exampleFormControlInput1" placeholder="name@example.com">
<div id="emailHelp" class="form-text">Make sure to specify the full url of the installation path of the website. <code>https://www.yourproject.com/ </code></div>
</div>
<hr class="mb-5">
<h3 class="mt-3">Database Info</h3>
<div class="mb-3 mt-5">
<label for="exampleFormControlInput1" class="form-label">Localhost</label>
<input type="text" name="dbloca" class="form-control" id="dblocal" placeholder="localhost" value="localhost">
<div id="emailHelp" class="form-text">Make sure to specify the full url of the installation path of the website. <code>https://www.yourproject.com/ </code></div>
</div>
<div class="mb-3">
<label for="exampleFormControlInput1" class="form-label">Databse Username</label>
<input type="text" name="dbuser" class="form-control" id="dbuser" placeholder="" value="">
<div id="emailHelp" class="form-text">Make sure to specify the full url of the installation path of the website. <code>https://www.yourproject.com/ </code></div>
</div>
<div class="mb-3">
<label for="exampleFormControlInput1" class="form-label">Databse Password</label>
<input type="text" name="dbpass" class="form-control" id="dbpass" placeholder="" value="">
<div id="emailHelp" class="form-text">Make sure to specify the full url of the installation path of the website. <code>https://www.yourproject.com/ </code></div>
</div>
<div class="mb-3">
<label for="exampleFormControlInput1" class="form-label">Databse Name</label>
<input type="text" name="dbname" class="form-control" id="dbname" placeholder="" value="">
<div id="emailHelp" class="form-text">Make sure to specify the full url of the installation path of the website. <code>https://www.yourproject.com/ </code></div>
</div>
<div class="mt-5">
<div id="dbstatus" class="alert" style="display: none"></div>
<input type="submit" class="btn btn-primary" value="Complete Installation">
<button class="btn btn-secondary" id="checkDB" type="button">Test Database Connection</button>
</div>
</form>
</div>
</section>
</main>

View file

@ -0,0 +1,20 @@
<?php
if($this->JavaScript) {
foreach ($this->JavaScript as $kj => $JavaScript) :
if(!is_numeric($kj)) {
echo PHP_EOL;
echo $JavaScript . PHP_EOL;
continue;
}
echo '<script src="'. $JavaScript .'"></script>' . PHP_EOL;
endforeach;
}
?>
<script src="/install/assets/bootstrap.bundle.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="CarlosArias.com">
<meta name="generator" content="">
<title>SeedProject - Framework Installation </title>
<link href="/install/assets/bootstrap.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
<?php
if($this->Styles) {
foreach ($this->Styles as $ks => $Styles) :
if(!is_numeric($ks)) {
echo PHP_EOL;
echo $Styles . PHP_EOL;
continue;
}
echo '<link type="text/css" rel="stylesheet" href="'. $Styles .'">' .PHP_EOL;
endforeach;
}
?>
</head>
<body>
<header>
<div class="collapse bg-dark" id="navbarHeader">
<div class="container">
<div class="row">
<div class="col-sm-8 col-md-7 py-4">
<h4 class="text-white">About</h4>
<p class="text-muted">Add some information about the album below, the author, or any other background context. Make it a few sentences long so folks can pick up some informative tidbits. Then, link them off to some social networking sites or contact information.</p>
</div>
<div class="col-sm-4 offset-md-1 py-4">
<h4 class="text-white">Contact</h4>
<ul class="list-unstyled">
<li><a href="#" class="text-white">Follow on Twitter</a></li>
<li><a href="#" class="text-white">Like on Facebook</a></li>
<li><a href="#" class="text-white">Email me</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="navbar navbar-dark bg-dark shadow-sm">
<div class="container">
<a href="#" class="navbar-brand d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2" viewBox="0 0 24 24"><path class="blueprint_een" d="M30,1H2C1.448,1,1,1.448,1,2v28c0,0.552,0.448,1,1,1h28c0.552,0,1-0.448,1-1V2
C31,1.448,30.552,1,30,1z M29,29H3V3h26V29z M5,8H4V4h4v1H5V8z M28,28h-4v-1h3v-3h1V28z M27,5h-3V4h4v4h-1V5z M8,28H4v-4h1v3h3V28z"/></svg>
<strong>SeedProject</strong>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarHeader" aria-controls="navbarHeader" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
</header>

21
api/manifest.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "SeedProject Framework",
"short_name" : "PWA",
"start_url": "/",
"scope" : "./",
"icons": [
{
"src": "/assets/icons/ComiidaIcon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/icons/ComiidaIcon.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffee00",
"background_color": "#ffee00",
"display": "standalone"
}

19
api/sw.js Normal file
View file

@ -0,0 +1,19 @@
/** An empty service worker! */
self.addEventListener ('install', e => {
console.log("Installed!");
e.waitUntil(
caches.open("static").then(cache => {
return cache.addAll(["/", "/assets/css/pwa.css", "/assets/icons/ComiidaIcon-192.png"]);
})
);
});
self.addEventListener("fetch", e => {
e.respondWith(
caches.match(e.request).then(response => {
return response || fetch(e.request);
})
);
});

170
api/system/ErrorHandler.php Normal file
View file

@ -0,0 +1,170 @@
<?php
class ErrorHandler {
private static array $errorTypeMap = [
E_ERROR => 'E_ERROR',
E_WARNING => 'E_WARNING',
E_PARSE => 'E_PARSE',
E_NOTICE => 'E_NOTICE',
E_CORE_ERROR => 'E_CORE_ERROR',
E_CORE_WARNING => 'E_CORE_WARNING',
E_COMPILE_ERROR => 'E_COMPILE_ERROR',
E_COMPILE_WARNING => 'E_COMPILE_WARNING',
E_USER_ERROR => 'E_USER_ERROR',
E_USER_WARNING => 'E_USER_WARNING',
E_USER_NOTICE => 'E_USER_NOTICE',
E_STRICT => 'E_STRICT',
E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR',
E_DEPRECATED => 'E_DEPRECATED',
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
];
private static array $errorTypeLabels = [
E_ERROR => 'Fatal Error',
E_WARNING => 'Warning',
E_PARSE => 'Parse Error',
E_NOTICE => 'Notice',
E_CORE_ERROR => 'Core Error',
E_CORE_WARNING => 'Core Warning',
E_COMPILE_ERROR => 'Compile Error',
E_COMPILE_WARNING => 'Compile Warning',
E_USER_ERROR => 'User Error',
E_USER_WARNING => 'User Warning',
E_USER_NOTICE => 'User Notice',
E_STRICT => 'Strict Standards',
E_RECOVERABLE_ERROR => 'Recoverable Error',
E_DEPRECATED => 'Deprecated',
E_USER_DEPRECATED => 'User Deprecated',
];
private static array $fatalTypes = [
E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR,
E_USER_ERROR, E_RECOVERABLE_ERROR,
];
public static function handleError($errno, $errstr, $errfile, $errline) {
if (!(error_reporting() & $errno)) {
return;
}
// Only persist fatal/critical errors — skip warnings, notices, deprecated, strict
if (in_array($errno, self::$fatalTypes)) {
self::persistError($errno, $errstr, $errfile, $errline);
self::logError($errno, $errstr, $errfile, $errline);
}
self::displayError($errno, $errstr, $errfile, $errline);
return true;
}
public static function handleException($exception) {
$trace = $exception->getTraceAsString();
self::persistError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine(), $trace, 'Exception');
self::logError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine());
self::displayError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine(), $trace);
}
public static function handleShutdown() {
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
self::persistError($error['type'], $error['message'], $error['file'], $error['line']);
self::logError($error['type'], $error['message'], $error['file'], $error['line']);
self::displayError($error['type'], $error['message'], $error['file'], $error['line']);
}
}
// ─── JSON flat-file persistence ───────────────────────────────────────────
private static function persistError($errno, $errstr, $errfile, $errline, $trace = null, $forcedType = null) {
try {
$errorType = $forcedType ?? (self::$errorTypeMap[$errno] ?? 'UNKNOWN');
$userId = null;
$orgId = null;
if (isset($_SESSION['login'])) {
$userId = $_SESSION['login']['userid'] ?? null;
$orgId = $_SESSION['login']['org_id'] ?? null;
}
$url = ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['REQUEST_URI'] ?? '');
$method = $_SERVER['REQUEST_METHOD'] ?? null;
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? null;
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
$referer = $_SERVER['HTTP_REFERER'] ?? null;
$entry = [
'id' => uniqid('err_', true),
'userid' => $userId,
'org_id' => $orgId,
'errortype' => $errorType,
'errorcode' => (string)$errno,
'url' => substr($url, 0, 512),
'file' => substr($errfile, 0, 512),
'line' => (int)$errline,
'fullerror' => $errstr,
'trace' => $trace,
'method' => $method ? substr($method, 0, 10) : null,
'ip_address' => $ipAddress ? substr($ipAddress, 0, 45) : null,
'user_agent' => $userAgent ? substr($userAgent, 0, 512) : null,
'referer' => $referer ? substr($referer, 0, 512) : null,
'status' => 'unresolved',
'createdate' => date('Y-m-d H:i:s'),
];
$logPath = __DIR__ . '/errors.json';
$entries = [];
if (file_exists($logPath)) {
$raw = file_get_contents($logPath);
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$entries = $decoded;
}
}
array_unshift($entries, $entry);
if (count($entries) > 1000) {
$entries = array_slice($entries, 0, 1000);
}
file_put_contents($logPath, json_encode($entries, JSON_PRETTY_PRINT), LOCK_EX);
} catch (\Throwable $e) {
// Silently fail — never let error logging crash the app
error_log('ErrorHandler::persistError failed: ' . $e->getMessage());
}
}
// ─── File logging ─────────────────────────────────────────────────────────
private static function logError($errno, $errstr, $errfile, $errline) {
$message = date('[Y-m-d H:i:s]') . " Error: [$errno] $errstr in $errfile on line $errline\n";
error_log($message, 3, __DIR__ . '/app_errors.log');
}
// ─── Display (debug mode only) ────────────────────────────────────────────
private static function displayError($errno, $errstr, $errfile, $errline, $trace = null) {
$errorType = self::$errorTypeLabels[$errno] ?? 'Unknown Error';
if (defined('DEBUG') && DEBUG) {
echo "<div style='background-color:#f8d7da;color:#721c24;padding:10px;margin:10px;border:1px solid #f5c6cb;border-radius:5px;'>";
echo "<h1 style='color:#721c24;'>" . htmlspecialchars($errorType) . " Occurred</h1>";
echo "<p><strong>Message:</strong> " . htmlspecialchars($errstr) . "</p>";
echo "<p><strong>File:</strong> " . htmlspecialchars($errfile) . "</p>";
echo "<p><strong>Line:</strong> " . htmlspecialchars((string)$errline) . "</p>";
if ($trace) {
echo "<h2>Stack Trace:</h2>";
echo "<pre>" . htmlspecialchars($trace) . "</pre>";
}
echo "<h2>Request Details:</h2><pre>";
echo "URL: " . htmlspecialchars($_SERVER['REQUEST_URI'] ?? '') . "\n";
echo "Method: " . htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? '') . "\n";
echo "Time: " . date('Y-m-d H:i:s') . "\n";
echo "IP: " . htmlspecialchars($_SERVER['REMOTE_ADDR'] ?? '') . "\n";
echo "</pre></div>";
}
}
}

362
api/system/errors.json Normal file
View file

@ -0,0 +1,362 @@
[
{
"id": "err_69ee699b38a214.70118470",
"userid": 2565,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
"line": 317,
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
"method": "POST",
"ip_address": "193.36.238.59",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
"status": "unresolved",
"createdate": "2026-04-26 15:38:03"
},
{
"id": "err_69ee698c669d29.22210069",
"userid": 2565,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
"line": 317,
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
"method": "POST",
"ip_address": "193.36.238.59",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
"status": "unresolved",
"createdate": "2026-04-26 15:37:48"
},
{
"id": "err_69ee6950756db9.31923142",
"userid": 2565,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
"line": 317,
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
"method": "POST",
"ip_address": "193.36.238.59",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
"status": "unresolved",
"createdate": "2026-04-26 15:36:48"
},
{
"id": "err_69ee6934c9c873.23488128",
"userid": 2565,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
"line": 317,
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
"method": "POST",
"ip_address": "193.36.238.59",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
"status": "unresolved",
"createdate": "2026-04-26 15:36:20"
},
{
"id": "err_69ee69273633b8.88201084",
"userid": 2565,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
"line": 317,
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
"method": "POST",
"ip_address": "193.36.238.59",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
"status": "unresolved",
"createdate": "2026-04-26 15:36:07"
},
{
"id": "err_69ed670a033b66.19048308",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/public\/",
"file": "\/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php",
"line": 131,
"fullerror": "Failed opening required 'public\/controllers\/index.php' (include_path='.:')",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#1 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#2 {main}",
"method": "GET",
"ip_address": "89.244.95.100",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/143.0.0.0 Safari\/537.36",
"referer": "https:\/\/secure.creditpullengine.com\/login",
"status": "unresolved",
"createdate": "2026-04-25 21:14:50"
},
{
"id": "err_69ed5d01f08370.23851037",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"Carbon\\Carbon\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "140.82.26.192",
"user_agent": "curl\/7.76.1",
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:32:01"
},
{
"id": "err_69ed5d01e8bb28.09924309",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"Carbon\\Carbon\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "207.246.75.27",
"user_agent": null,
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:32:01"
},
{
"id": "err_69ed5cf5670d39.78662554",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"Carbon\\Carbon\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "206.62.143.66",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:31:49"
},
{
"id": "err_69ed5cdaeef019.29989883",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"Carbon\\Carbon\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "206.62.143.66",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:31:22"
},
{
"id": "err_69ed5cc5a99338.48739755",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"Carbon\\Carbon\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "207.246.75.27",
"user_agent": null,
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:31:01"
},
{
"id": "err_69ed5cc5a5d377.51767422",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"Carbon\\Carbon\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "140.82.26.192",
"user_agent": "curl\/7.76.1",
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:31:01"
},
{
"id": "err_69ed5c896b0de3.37548309",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"App\\Core\\StripeService\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "207.246.75.27",
"user_agent": null,
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:30:01"
},
{
"id": "err_69ed5c895c99d9.73562260",
"userid": null,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
"line": 14,
"fullerror": "Class \"App\\Core\\StripeService\" not found",
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "140.82.26.192",
"user_agent": "curl\/7.76.1",
"referer": null,
"status": "unresolved",
"createdate": "2026-04-25 20:30:01"
},
{
"id": "err_69e773f2990218.94993625",
"userid": 2548,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "http:\/\/nsecure.creditpullengine.com\/",
"file": "\/www\/wwwroot\/nSecure\/core\/View.php",
"line": 19,
"fullerror": "Failed opening required '\/www\/wwwroot\/nSecure\/public\/views\/index\/orgadmin.php' (include_path='.:')",
"trace": "#0 \/www\/wwwroot\/nSecure\/public\/controllers\/index.php(25): View->render()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(133): Index->index()\n#2 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#3 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#4 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#5 {main}",
"method": "GET",
"ip_address": "172.70.55.166",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/nsecure.creditpullengine.com\/company\/consumer-shield-llc.ZhYbs5uFyhX2G7JfdVIGsmxCGaDRq2A91vcUedk5_yU",
"status": "unresolved",
"createdate": "2026-04-21 08:56:18"
},
{
"id": "err_69e77133a9dbc2.26211292",
"userid": 3,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "http:\/\/nsecure.creditpullengine.com\/pulls\/view\/1427.f5CHVieZZZGJISi3HECOj9z-oRbIbCuYysAbz22Y9oI",
"file": "\/www\/wwwroot\/nSecure\/public\/controllers\/pulls.php",
"line": 104,
"fullerror": "Call to undefined function redirect()",
"trace": "#0 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(222): pulls->view()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "172.70.55.166",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": null,
"status": "unresolved",
"createdate": "2026-04-21 08:44:35"
},
{
"id": "err_69e77014a27279.45734769",
"userid": 2566,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "http:\/\/nsecure.creditpullengine.com\/",
"file": "\/www\/wwwroot\/nSecure\/core\/View.php",
"line": 19,
"fullerror": "Failed opening required '\/www\/wwwroot\/nSecure\/public\/views\/index\/orgadmin.php' (include_path='.:')",
"trace": "#0 \/www\/wwwroot\/nSecure\/public\/controllers\/index.php(25): View->render()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(133): Index->index()\n#2 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#3 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#4 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#5 {main}",
"method": "GET",
"ip_address": "104.23.237.20",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/nsecure.creditpullengine.com\/admin\/users",
"status": "unresolved",
"createdate": "2026-04-21 08:39:48"
},
{
"id": "err_69e76e93d1cc43.29318732",
"userid": 3,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "http:\/\/nsecure.creditpullengine.com\/pulls\/view\/1427.f5CHVieZZZGJISi3HECOj9z-oRbIbCuYysAbz22Y9oI",
"file": "\/www\/wwwroot\/nSecure\/public\/controllers\/pulls.php",
"line": 104,
"fullerror": "Call to undefined function redirect()",
"trace": "#0 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(222): pulls->view()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "104.23.237.20",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/nsecure.creditpullengine.com\/reports\/pulls\/consumer-shield-llc.YAj8lpRtFakNZbbopBFBpJGCDUF7bXC5B3HzJg0I3Bw",
"status": "unresolved",
"createdate": "2026-04-21 08:33:23"
},
{
"id": "err_69e76bb0efcf58.05946519",
"userid": 3,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "http:\/\/nsecure.creditpullengine.com\/pulls\/view\/1459.jDV7q5dcrBikg_9FMFi4k6ZMtZokNkCl9Uk6JMYOIZg",
"file": "\/www\/wwwroot\/nSecure\/public\/controllers\/pulls.php",
"line": 104,
"fullerror": "Call to undefined function redirect()",
"trace": "#0 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(222): pulls->view()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#4 {main}",
"method": "GET",
"ip_address": "198.41.231.17",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/nsecure.creditpullengine.com\/reports\/pulls\/limpia-deudas-llc._i0s98IqNv38bmtnqrE7eluqQNSKLtkOdaJu2qOk3-I",
"status": "unresolved",
"createdate": "2026-04-21 08:21:04"
},
{
"id": "err_69e76b1f397f19.04102361",
"userid": 2565,
"org_id": null,
"errortype": "Exception",
"errorcode": "1",
"url": "http:\/\/nsecure.creditpullengine.com\/",
"file": "\/www\/wwwroot\/nSecure\/core\/View.php",
"line": 19,
"fullerror": "Failed opening required '\/www\/wwwroot\/nSecure\/public\/views\/index\/orgadmin.php' (include_path='.:')",
"trace": "#0 \/www\/wwwroot\/nSecure\/public\/controllers\/index.php(25): View->render()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(133): Index->index()\n#2 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#3 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#4 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#5 {main}",
"method": "GET",
"ip_address": "172.68.7.18",
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
"referer": "https:\/\/nsecure.creditpullengine.com\/admin\/users",
"status": "unresolved",
"createdate": "2026-04-21 08:18:39"
}
]

View file

@ -0,0 +1,711 @@
---
name: ui-ux-pro-max
description: "UI/UX design intelligence for web and mobile. Includes 50+ styles, 161 color palettes, 57 font pairings, 161 product types, 99 UX guidelines, and 25 chart types across 10 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui, and HTML/CSS). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, and check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, and mobile app. Elements: button, modal, navbar, sidebar, card, table, form, and chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, and flat design. Topics: color systems, accessibility, animation, layout, typography, font pairing, spacing, interaction states, shadow, and gradient. Integrations: shadcn/ui MCP for component search and examples."
---
> **astroagent environment note:** You run headless in a sandbox with **no shell/scripts**. Ignore any instruction here to run search scripts or `--domain` / `--design-system` / CLI queries — apply the written guidance directly. This project is an **Astro + Tailwind** website: prefer the Web/CSS/Tailwind rules and treat native iOS/Android-only items (haptics, VoiceOver, safe-area) as optional. Always match the project's existing design system first (read AGENTS.md / DESIGN.md and nearby components).
# UI/UX Pro Max - Design Intelligence
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 161 color palettes, 57 font pairings, 161 product types with reasoning rules, 99 UX guidelines, and 25 chart types across 10 technology stacks. Searchable database with priority-based recommendations.
## When to Apply
This Skill should be used when the task involves **UI structure, visual design decisions, interaction patterns, or user experience quality control**.
### Must Use
This Skill must be invoked in the following situations:
- Designing new pages (Landing Page, Dashboard, Admin, SaaS, Mobile App)
- Creating or refactoring UI components (buttons, modals, forms, tables, charts, etc.)
- Choosing color schemes, typography systems, spacing standards, or layout systems
- Reviewing UI code for user experience, accessibility, or visual consistency
- Implementing navigation structures, animations, or responsive behavior
- Making product-level design decisions (style, information hierarchy, brand expression)
- Improving perceived quality, clarity, or usability of interfaces
### Recommended
This Skill is recommended in the following situations:
- UI looks "not professional enough" but the reason is unclear
- Receiving feedback on usability or experience
- Pre-launch UI quality optimization
- Aligning cross-platform design (Web / iOS / Android)
- Building design systems or reusable component libraries
### Skip
This Skill is not needed in the following situations:
- Pure backend logic development
- Only involving API or database design
- Performance optimization unrelated to the interface
- Infrastructure or DevOps work
- Non-visual scripts or automation tasks
**Decision criteria**: If the task will change how a feature **looks, feels, moves, or is interacted with**, this Skill should be used.
## Rule Categories by Priority
*For human/AI reference: follow priority 1→10 to decide which rule category to focus on first; use `--domain <Domain>` to query details when needed. Scripts do not read this table.*
| Priority | Category | Impact | Domain | Key Checks (Must Have) | Anti-Patterns (Avoid) |
|----------|----------|--------|--------|------------------------|------------------------|
| 1 | Accessibility | CRITICAL | `ux` | Contrast 4.5:1, Alt text, Keyboard nav, Aria-labels | Removing focus rings, Icon-only buttons without labels |
| 2 | Touch & Interaction | CRITICAL | `ux` | Min size 44×44px, 8px+ spacing, Loading feedback | Reliance on hover only, Instant state changes (0ms) |
| 3 | Performance | HIGH | `ux` | WebP/AVIF, Lazy loading, Reserve space (CLS &lt; 0.1) | Layout thrashing, Cumulative Layout Shift |
| 4 | Style Selection | HIGH | `style`, `product` | Match product type, Consistency, SVG icons (no emoji) | Mixing flat & skeuomorphic randomly, Emoji as icons |
| 5 | Layout & Responsive | HIGH | `ux` | Mobile-first breakpoints, Viewport meta, No horizontal scroll | Horizontal scroll, Fixed px container widths, Disable zoom |
| 6 | Typography & Color | MEDIUM | `typography`, `color` | Base 16px, Line-height 1.5, Semantic color tokens | Text &lt; 12px body, Gray-on-gray, Raw hex in components |
| 7 | Animation | MEDIUM | `ux` | Duration 150300ms, Motion conveys meaning, Spatial continuity | Decorative-only animation, Animating width/height, No reduced-motion |
| 8 | Forms & Feedback | MEDIUM | `ux` | Visible labels, Error near field, Helper text, Progressive disclosure | Placeholder-only label, Errors only at top, Overwhelm upfront |
| 9 | Navigation Patterns | HIGH | `ux` | Predictable back, Bottom nav ≤5, Deep linking | Overloaded nav, Broken back behavior, No deep links |
| 10 | Charts & Data | LOW | `chart` | Legends, Tooltips, Accessible colors | Relying on color alone to convey meaning |
## Quick Reference
### 1. Accessibility (CRITICAL)
- `color-contrast` - Minimum 4.5:1 ratio for normal text (large text 3:1); Material Design
- `focus-states` - Visible focus rings on interactive elements (24px; Apple HIG, MD)
- `alt-text` - Descriptive alt text for meaningful images
- `aria-labels` - aria-label for icon-only buttons; accessibilityLabel in native (Apple HIG)
- `keyboard-nav` - Tab order matches visual order; full keyboard support (Apple HIG)
- `form-labels` - Use label with for attribute
- `skip-links` - Skip to main content for keyboard users
- `heading-hierarchy` - Sequential h1→h6, no level skip
- `color-not-only` - Don't convey info by color alone (add icon/text)
- `dynamic-type` - Support system text scaling; avoid truncation as text grows (Apple Dynamic Type, MD)
- `reduced-motion` - Respect prefers-reduced-motion; reduce/disable animations when requested (Apple Reduced Motion API, MD)
- `voiceover-sr` - Meaningful accessibilityLabel/accessibilityHint; logical reading order for VoiceOver/screen readers (Apple HIG, MD)
- `escape-routes` - Provide cancel/back in modals and multi-step flows (Apple HIG)
- `keyboard-shortcuts` - Preserve system and a11y shortcuts; offer keyboard alternatives for drag-and-drop (Apple HIG)
### 2. Touch & Interaction (CRITICAL)
- `touch-target-size` - Min 44×44pt (Apple) / 48×48dp (Material); extend hit area beyond visual bounds if needed
- `touch-spacing` - Minimum 8px/8dp gap between touch targets (Apple HIG, MD)
- `hover-vs-tap` - Use click/tap for primary interactions; don't rely on hover alone
- `loading-buttons` - Disable button during async operations; show spinner or progress
- `error-feedback` - Clear error messages near problem
- `cursor-pointer` - Add cursor-pointer to clickable elements (Web)
- `gesture-conflicts` - Avoid horizontal swipe on main content; prefer vertical scroll
- `tap-delay` - Use touch-action: manipulation to reduce 300ms delay (Web)
- `standard-gestures` - Use platform standard gestures consistently; don't redefine (e.g. swipe-back, pinch-zoom) (Apple HIG)
- `system-gestures` - Don't block system gestures (Control Center, back swipe, etc.) (Apple HIG)
- `press-feedback` - Visual feedback on press (ripple/highlight; MD state layers)
- `haptic-feedback` - Use haptic for confirmations and important actions; avoid overuse (Apple HIG)
- `gesture-alternative` - Don't rely on gesture-only interactions; always provide visible controls for critical actions
- `safe-area-awareness` - Keep primary touch targets away from notch, Dynamic Island, gesture bar and screen edges
- `no-precision-required` - Avoid requiring pixel-perfect taps on small icons or thin edges
- `swipe-clarity` - Swipe actions must show clear affordance or hint (chevron, label, tutorial)
- `drag-threshold` - Use a movement threshold before starting drag to avoid accidental drags
### 3. Performance (HIGH)
- `image-optimization` - Use WebP/AVIF, responsive images (srcset/sizes), lazy load non-critical assets
- `image-dimension` - Declare width/height or use aspect-ratio to prevent layout shift (Core Web Vitals: CLS)
- `font-loading` - Use font-display: swap/optional to avoid invisible text (FOIT); reserve space to reduce layout shift (MD)
- `font-preload` - Preload only critical fonts; avoid overusing preload on every variant
- `critical-css` - Prioritize above-the-fold CSS (inline critical CSS or early-loaded stylesheet)
- `lazy-loading` - Lazy load non-hero components via dynamic import / route-level splitting
- `bundle-splitting` - Split code by route/feature (React Suspense / Next.js dynamic) to reduce initial load and TTI
- `third-party-scripts` - Load third-party scripts async/defer; audit and remove unnecessary ones (MD)
- `reduce-reflows` - Avoid frequent layout reads/writes; batch DOM reads then writes
- `content-jumping` - Reserve space for async content to avoid layout jumps (Core Web Vitals: CLS)
- `lazy-load-below-fold` - Use loading="lazy" for below-the-fold images and heavy media
- `virtualize-lists` - Virtualize lists with 50+ items to improve memory efficiency and scroll performance
- `main-thread-budget` - Keep per-frame work under ~16ms for 60fps; move heavy tasks off main thread (HIG, MD)
- `progressive-loading` - Use skeleton screens / shimmer instead of long blocking spinners for >1s operations (Apple HIG)
- `input-latency` - Keep input latency under ~100ms for taps/scrolls (Material responsiveness standard)
- `tap-feedback-speed` - Provide visual feedback within 100ms of tap (Apple HIG)
- `debounce-throttle` - Use debounce/throttle for high-frequency events (scroll, resize, input)
- `offline-support` - Provide offline state messaging and basic fallback (PWA / mobile)
- `network-fallback` - Offer degraded modes for slow networks (lower-res images, fewer animations)
### 4. Style Selection (HIGH)
- `style-match` - Match style to product type (use `--design-system` for recommendations)
- `consistency` - Use same style across all pages
- `no-emoji-icons` - Use SVG icons (Heroicons, Lucide), not emojis
- `color-palette-from-product` - Choose palette from product/industry (search `--domain color`)
- `effects-match-style` - Shadows, blur, radius aligned with chosen style (glass / flat / clay etc.)
- `platform-adaptive` - Respect platform idioms (iOS HIG vs Material): navigation, controls, typography, motion
- `state-clarity` - Make hover/pressed/disabled states visually distinct while staying on-style (Material state layers)
- `elevation-consistent` - Use a consistent elevation/shadow scale for cards, sheets, modals; avoid random shadow values
- `dark-mode-pairing` - Design light/dark variants together to keep brand, contrast, and style consistent
- `icon-style-consistent` - Use one icon set/visual language (stroke width, corner radius) across the product
- `system-controls` - Prefer native/system controls over fully custom ones; only customize when branding requires it (Apple HIG)
- `blur-purpose` - Use blur to indicate background dismissal (modals, sheets), not as decoration (Apple HIG)
- `primary-action` - Each screen should have only one primary CTA; secondary actions visually subordinate (Apple HIG)
### 5. Layout & Responsive (HIGH)
- `viewport-meta` - width=device-width initial-scale=1 (never disable zoom)
- `mobile-first` - Design mobile-first, then scale up to tablet and desktop
- `breakpoint-consistency` - Use systematic breakpoints (e.g. 375 / 768 / 1024 / 1440)
- `readable-font-size` - Minimum 16px body text on mobile (avoids iOS auto-zoom)
- `line-length-control` - Mobile 3560 chars per line; desktop 6075 chars
- `horizontal-scroll` - No horizontal scroll on mobile; ensure content fits viewport width
- `spacing-scale` - Use 4pt/8dp incremental spacing system (Material Design)
- `touch-density` - Keep component spacing comfortable for touch: not cramped, not causing mis-taps
- `container-width` - Consistent max-width on desktop (max-w-6xl / 7xl)
- `z-index-management` - Define layered z-index scale (e.g. 0 / 10 / 20 / 40 / 100 / 1000)
- `fixed-element-offset` - Fixed navbar/bottom bar must reserve safe padding for underlying content
- `scroll-behavior` - Avoid nested scroll regions that interfere with the main scroll experience
- `viewport-units` - Prefer min-h-dvh over 100vh on mobile
- `orientation-support` - Keep layout readable and operable in landscape mode
- `content-priority` - Show core content first on mobile; fold or hide secondary content
- `visual-hierarchy` - Establish hierarchy via size, spacing, contrast — not color alone
### 6. Typography & Color (MEDIUM)
- `line-height` - Use 1.5-1.75 for body text
- `line-length` - Limit to 65-75 characters per line
- `font-pairing` - Match heading/body font personalities
- `font-scale` - Consistent type scale (e.g. 12 14 16 18 24 32)
- `contrast-readability` - Darker text on light backgrounds (e.g. slate-900 on white)
- `text-styles-system` - Use platform type system: iOS 11 Dynamic Type styles / Material 5 type roles (display, headline, title, body, label) (HIG, MD)
- `weight-hierarchy` - Use font-weight to reinforce hierarchy: Bold headings (600700), Regular body (400), Medium labels (500) (MD)
- `color-semantic` - Define semantic color tokens (primary, secondary, error, surface, on-surface) not raw hex in components (Material color system)
- `color-dark-mode` - Dark mode uses desaturated / lighter tonal variants, not inverted colors; test contrast separately (HIG, MD)
- `color-accessible-pairs` - Foreground/background pairs must meet 4.5:1 (AA) or 7:1 (AAA); use tools to verify (WCAG, MD)
- `color-not-decorative-only` - Functional color (error red, success green) must include icon/text; avoid color-only meaning (HIG, MD)
- `truncation-strategy` - Prefer wrapping over truncation; when truncating use ellipsis and provide full text via tooltip/expand (Apple HIG)
- `letter-spacing` - Respect default letter-spacing per platform; avoid tight tracking on body text (HIG, MD)
- `number-tabular` - Use tabular/monospaced figures for data columns, prices, and timers to prevent layout shift
- `whitespace-balance` - Use whitespace intentionally to group related items and separate sections; avoid visual clutter (Apple HIG)
### 7. Animation (MEDIUM)
- `duration-timing` - Use 150300ms for micro-interactions; complex transitions ≤400ms; avoid >500ms (MD)
- `transform-performance` - Use transform/opacity only; avoid animating width/height/top/left
- `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms
- `excessive-motion` - Animate 1-2 key elements per view max
- `easing` - Use ease-out for entering, ease-in for exiting; avoid linear for UI transitions
- `motion-meaning` - Every animation must express a cause-effect relationship, not just be decorative (Apple HIG)
- `state-transition` - State changes (hover / active / expanded / collapsed / modal) should animate smoothly, not snap
- `continuity` - Page/screen transitions should maintain spatial continuity (shared element, directional slide) (Apple HIG)
- `parallax-subtle` - Use parallax sparingly; must respect reduced-motion and not cause disorientation (Apple HIG)
- `spring-physics` - Prefer spring/physics-based curves over linear or cubic-bezier for natural feel (Apple HIG fluid animations)
- `exit-faster-than-enter` - Exit animations shorter than enter (~6070% of enter duration) to feel responsive (MD motion)
- `stagger-sequence` - Stagger list/grid item entrance by 3050ms per item; avoid all-at-once or too-slow reveals (MD)
- `shared-element-transition` - Use shared element / hero transitions for visual continuity between screens (MD, HIG)
- `interruptible` - Animations must be interruptible; user tap/gesture cancels in-progress animation immediately (Apple HIG)
- `no-blocking-animation` - Never block user input during an animation; UI must stay interactive (Apple HIG)
- `fade-crossfade` - Use crossfade for content replacement within the same container (MD)
- `scale-feedback` - Subtle scale (0.951.05) on press for tappable cards/buttons; restore on release (HIG, MD)
- `gesture-feedback` - Drag, swipe, and pinch must provide real-time visual response tracking the finger (MD Motion)
- `hierarchy-motion` - Use translate/scale direction to express hierarchy: enter from below = deeper, exit upward = back (MD)
- `motion-consistency` - Unify duration/easing tokens globally; all animations share the same rhythm and feel
- `opacity-threshold` - Fading elements should not linger below opacity 0.2; either fade fully or remain visible
- `modal-motion` - Modals/sheets should animate from their trigger source (scale+fade or slide-in) for spatial context (HIG, MD)
- `navigation-direction` - Forward navigation animates left/up; backward animates right/down — keep direction logically consistent (HIG)
- `layout-shift-avoid` - Animations must not cause layout reflow or CLS; use transform for position changes
### 8. Forms & Feedback (MEDIUM)
- `input-labels` - Visible label per input (not placeholder-only)
- `error-placement` - Show error below the related field
- `submit-feedback` - Loading then success/error state on submit
- `required-indicators` - Mark required fields (e.g. asterisk)
- `empty-states` - Helpful message and action when no content
- `toast-dismiss` - Auto-dismiss toasts in 3-5s
- `confirmation-dialogs` - Confirm before destructive actions
- `input-helper-text` - Provide persistent helper text below complex inputs, not just placeholder (Material Design)
- `disabled-states` - Disabled elements use reduced opacity (0.380.5) + cursor change + semantic attribute (MD)
- `progressive-disclosure` - Reveal complex options progressively; don't overwhelm users upfront (Apple HIG)
- `inline-validation` - Validate on blur (not keystroke); show error only after user finishes input (MD)
- `input-type-keyboard` - Use semantic input types (email, tel, number) to trigger the correct mobile keyboard (HIG, MD)
- `password-toggle` - Provide show/hide toggle for password fields (MD)
- `autofill-support` - Use autocomplete / textContentType attributes so the system can autofill (HIG, MD)
- `undo-support` - Allow undo for destructive or bulk actions (e.g. "Undo delete" toast) (Apple HIG)
- `success-feedback` - Confirm completed actions with brief visual feedback (checkmark, toast, color flash) (MD)
- `error-recovery` - Error messages must include a clear recovery path (retry, edit, help link) (HIG, MD)
- `multi-step-progress` - Multi-step flows show step indicator or progress bar; allow back navigation (MD)
- `form-autosave` - Long forms should auto-save drafts to prevent data loss on accidental dismissal (Apple HIG)
- `sheet-dismiss-confirm` - Confirm before dismissing a sheet/modal with unsaved changes (Apple HIG)
- `error-clarity` - Error messages must state cause + how to fix (not just "Invalid input") (HIG, MD)
- `field-grouping` - Group related fields logically (fieldset/legend or visual grouping) (MD)
- `read-only-distinction` - Read-only state should be visually and semantically different from disabled (MD)
- `focus-management` - After submit error, auto-focus the first invalid field (WCAG, MD)
- `error-summary` - For multiple errors, show summary at top with anchor links to each field (WCAG)
- `touch-friendly-input` - Mobile input height ≥44px to meet touch target requirements (Apple HIG)
- `destructive-emphasis` - Destructive actions use semantic danger color (red) and are visually separated from primary actions (HIG, MD)
- `toast-accessibility` - Toasts must not steal focus; use aria-live="polite" for screen reader announcement (WCAG)
- `aria-live-errors` - Form errors use aria-live region or role="alert" to notify screen readers (WCAG)
- `contrast-feedback` - Error and success state colors must meet 4.5:1 contrast ratio (WCAG, MD)
- `timeout-feedback` - Request timeout must show clear feedback with retry option (MD)
### 9. Navigation Patterns (HIGH)
- `bottom-nav-limit` - Bottom navigation max 5 items; use labels with icons (Material Design)
- `drawer-usage` - Use drawer/sidebar for secondary navigation, not primary actions (Material Design)
- `back-behavior` - Back navigation must be predictable and consistent; preserve scroll/state (Apple HIG, MD)
- `deep-linking` - All key screens must be reachable via deep link / URL for sharing and notifications (Apple HIG, MD)
- `tab-bar-ios` - iOS: use bottom Tab Bar for top-level navigation (Apple HIG)
- `top-app-bar-android` - Android: use Top App Bar with navigation icon for primary structure (Material Design)
- `nav-label-icon` - Navigation items must have both icon and text label; icon-only nav harms discoverability (MD)
- `nav-state-active` - Current location must be visually highlighted (color, weight, indicator) in navigation (HIG, MD)
- `nav-hierarchy` - Primary nav (tabs/bottom bar) vs secondary nav (drawer/settings) must be clearly separated (MD)
- `modal-escape` - Modals and sheets must offer a clear close/dismiss affordance; swipe-down to dismiss on mobile (Apple HIG)
- `search-accessible` - Search must be easily reachable (top bar or tab); provide recent/suggested queries (MD)
- `breadcrumb-web` - Web: use breadcrumbs for 3+ level deep hierarchies to aid orientation (MD)
- `state-preservation` - Navigating back must restore previous scroll position, filter state, and input (HIG, MD)
- `gesture-nav-support` - Support system gesture navigation (iOS swipe-back, Android predictive back) without conflict (HIG, MD)
- `tab-badge` - Use badges on nav items sparingly to indicate unread/pending; clear after user visits (HIG, MD)
- `overflow-menu` - When actions exceed available space, use overflow/more menu instead of cramming (MD)
- `bottom-nav-top-level` - Bottom nav is for top-level screens only; never nest sub-navigation inside it (MD)
- `adaptive-navigation` - Large screens (≥1024px) prefer sidebar; small screens use bottom/top nav (Material Adaptive)
- `back-stack-integrity` - Never silently reset the navigation stack or unexpectedly jump to home (HIG, MD)
- `navigation-consistency` - Navigation placement must stay the same across all pages; don't change by page type
- `avoid-mixed-patterns` - Don't mix Tab + Sidebar + Bottom Nav at the same hierarchy level
- `modal-vs-navigation` - Modals must not be used for primary navigation flows; they break the user's path (HIG)
- `focus-on-route-change` - After page transition, move focus to main content region for screen reader users (WCAG)
- `persistent-nav` - Core navigation must remain reachable from deep pages; don't hide it entirely in sub-flows (HIG, MD)
- `destructive-nav-separation` - Dangerous actions (delete account, logout) must be visually and spatially separated from normal nav items (HIG, MD)
- `empty-nav-state` - When a nav destination is unavailable, explain why instead of silently hiding it (MD)
### 10. Charts & Data (LOW)
- `chart-type` - Match chart type to data type (trend → line, comparison → bar, proportion → pie/donut)
- `color-guidance` - Use accessible color palettes; avoid red/green only pairs for colorblind users (WCAG, MD)
- `data-table` - Provide table alternative for accessibility; charts alone are not screen-reader friendly (WCAG)
- `pattern-texture` - Supplement color with patterns, textures, or shapes so data is distinguishable without color (WCAG, MD)
- `legend-visible` - Always show legend; position near the chart, not detached below a scroll fold (MD)
- `tooltip-on-interact` - Provide tooltips/data labels on hover (Web) or tap (mobile) showing exact values (HIG, MD)
- `axis-labels` - Label axes with units and readable scale; avoid truncated or rotated labels on mobile
- `responsive-chart` - Charts must reflow or simplify on small screens (e.g. horizontal bar instead of vertical, fewer ticks)
- `empty-data-state` - Show meaningful empty state when no data exists ("No data yet" + guidance), not a blank chart (MD)
- `loading-chart` - Use skeleton or shimmer placeholder while chart data loads; don't show an empty axis frame
- `animation-optional` - Chart entrance animations must respect prefers-reduced-motion; data should be readable immediately (HIG)
- `large-dataset` - For 1000+ data points, aggregate or sample; provide drill-down for detail instead of rendering all (MD)
- `number-formatting` - Use locale-aware formatting for numbers, dates, currencies on axes and labels (HIG, MD)
- `touch-target-chart` - Interactive chart elements (points, segments) must have ≥44pt tap area or expand on touch (Apple HIG)
- `no-pie-overuse` - Avoid pie/donut for >5 categories; switch to bar chart for clarity
- `contrast-data` - Data lines/bars vs background ≥3:1; data text labels ≥4.5:1 (WCAG)
- `legend-interactive` - Legends should be clickable to toggle series visibility (MD)
- `direct-labeling` - For small datasets, label values directly on the chart to reduce eye travel
- `tooltip-keyboard` - Tooltip content must be keyboard-reachable and not rely on hover alone (WCAG)
- `sortable-table` - Data tables must support sorting with aria-sort indicating current sort state (WCAG)
- `axis-readability` - Axis ticks must not be cramped; maintain readable spacing, auto-skip on small screens
- `data-density` - Limit information density per chart to avoid cognitive overload; split into multiple charts if needed
- `trend-emphasis` - Emphasize data trends over decoration; avoid heavy gradients/shadows that obscure the data
- `gridline-subtle` - Grid lines should be low-contrast (e.g. gray-200) so they don't compete with data
- `focusable-elements` - Interactive chart elements (points, bars, slices) must be keyboard-navigable (WCAG)
- `screen-reader-summary` - Provide a text summary or aria-label describing the chart's key insight for screen readers (WCAG)
- `error-state-chart` - Data load failure must show error message with retry action, not a broken/empty chart
- `export-option` - For data-heavy products, offer CSV/image export of chart data
- `drill-down-consistency` - Drill-down interactions must maintain a clear back-path and hierarchy breadcrumb
- `time-scale-clarity` - Time series charts must clearly label time granularity (day/week/month) and allow switching
## How to Use
Search specific domains using the CLI tool below.
---
## Prerequisites
Check if Python is installed:
```bash
python3 --version || python --version
```
If Python is not installed, install it based on user's OS:
**macOS:**
```bash
brew install python3
```
**Ubuntu/Debian:**
```bash
sudo apt update && sudo apt install python3
```
**Windows:**
```powershell
winget install Python.Python.3.12
```
> **Note:** On Windows, use `python` instead of `python3` to run scripts (e.g., `python scripts/search.py` instead of `python3 scripts/search.py`).
---
## How to Use This Skill
Use this skill when the user requests any of the following:
| Scenario | Trigger Examples | Start From |
|----------|-----------------|------------|
| **New project / page** | "Build a landing page", "Build a dashboard" | Step 1 → Step 2 (design system) |
| **New component** | "Create a pricing card", "Add a modal" | Step 3 (domain search: style, ux) |
| **Choose style / color / font** | "What style fits a fintech app?", "Recommend a color palette" | Step 2 (design system) |
| **Review existing UI** | "Review this page for UX issues", "Check accessibility" | Quick Reference checklist above |
| **Fix a UI bug** | "Button hover is broken", "Layout shifts on load" | Quick Reference → relevant section |
| **Improve / optimize** | "Make this faster", "Improve mobile experience" | Step 3 (domain search: ux, react) |
| **Implement dark mode** | "Add dark mode support" | Step 3 (domain: style "dark mode") |
| **Add charts / data viz** | "Add an analytics dashboard chart" | Step 3 (domain: chart) |
| **Stack best practices** | "React performance tips"、"SwiftUI navigation" | Step 4 (stack search) |
Follow this workflow:
### Step 1: Analyze User Requirements
Extract key information from user request:
- **Product type**: Entertainment (social, video, music, gaming), Tool (scanner, editor, converter), Productivity (task manager, notes, calendar), or hybrid
- **Target audience**: C-end consumer users; consider age group, usage context (commute, leisure, work)
- **Style keywords**: playful, vibrant, minimal, dark mode, content-first, immersive, etc.
- **Stack**: Match the project's framework. The engine ships guidance for many stacks (see [Available Stacks](#available-stacks) below) — pass the matching `--stack` (e.g. `nextjs`, `react`, `shadcn`, `vue`, `svelte`, `astro`, `swiftui`, `flutter`, `react-native`).
### Step 2: Generate Design System (REQUIRED)
**Always start with `--design-system`** to get comprehensive recommendations with reasoning:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<product_type> <industry> <keywords>" --design-system [-p "Project Name"]
```
This command:
1. Searches domains in parallel (product, style, color, landing, typography)
2. Applies reasoning rules from `ui-reasoning.csv` to select best matches
3. Returns complete design system: pattern, style, colors, typography, effects
4. Includes anti-patterns to avoid
**Example:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa"
```
### Step 2b: Persist Design System (Master + Overrides Pattern)
To save the design system for **hierarchical retrieval across sessions**, add `--persist`:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name"
```
This creates:
- `design-system/MASTER.md` — Global Source of Truth with all design rules
- `design-system/pages/` — Folder for page-specific overrides
**With page-specific override:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" --page "dashboard"
```
This also creates:
- `design-system/pages/dashboard.md` — Page-specific deviations from Master
**How hierarchical retrieval works:**
1. When building a specific page (e.g., "Checkout"), first check `design-system/pages/checkout.md`
2. If the page file exists, its rules **override** the Master file
3. If not, use `design-system/MASTER.md` exclusively
**Context-aware retrieval prompt:**
```
I am building the [Page Name] page. Please read design-system/MASTER.md.
Also check if design-system/pages/[page-name].md exists.
If the page file exists, prioritize its rules.
If not, use the Master rules exclusively.
Now, generate the code...
```
### Step 2c: Design Dials (optional)
Three optional 1-10 sliders that tune `--design-system` output without changing your query. Add any combination of them to the same command:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --variance <1-10> --motion <1-10> --density <1-10>
```
| Dial | Low (1-3) | Mid (4-7) | High (8-10) |
|------|-----------|-----------|-------------|
| `--variance` | Centered / minimal (biases toward Minimalism-style categories) | Balanced / modern | Bold / asymmetric (biases toward Brutalism, Bento Grids) |
| `--motion` | Subtle micro-interactions | Standard scroll/stagger motion | Complex choreography (pin, Flip, SplitText) |
| `--density` | Spacious (24-96px spacing scale) | Standard (16-64px, current default) | Dense/dashboard (8-32px spacing scale) |
- `--motion` attaches a ready-to-use GSAP snippet (with framework notes, Do/Don't, and performance notes) pulled from `--domain gsap`, matched to the resolved tier (Subtle/Standard/Complex).
- `--density` overrides the `--space-*` CSS variable table in the ASCII/markdown/MASTER.md output — use it for dashboards (high) vs. marketing pages (low) without hand-editing tokens.
- Leaving a dial unset keeps that part of the output exactly as it was before (no behavior change).
**Example:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "internal analytics dashboard" --design-system --variance 8 --motion 7 --density 8 -p "Ops Console"
```
### Step 3: Supplement with Detailed Searches (as needed)
After getting the design system, use domain searches to get additional details:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>]
```
**When to use detailed searches:**
| Need | Domain | Example |
|------|--------|---------|
| Product type patterns | `product` | `--domain product "entertainment social"` |
| More style options | `style` | `--domain style "glassmorphism dark"` |
| Color palettes | `color` | `--domain color "entertainment vibrant"` |
| Font pairings | `typography` | `--domain typography "playful modern"` |
| Chart recommendations | `chart` | `--domain chart "real-time dashboard"` |
| UX best practices | `ux` | `--domain ux "animation accessibility"` |
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
| Individual Google Fonts | `google-fonts` | `--domain google-fonts "sans serif popular variable"` |
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
| React Native perf | `react` | `--domain react "rerender memo list"` |
| App interface a11y | `web` | `--domain web "accessibilityLabel touch safe-areas"` |
| AI prompt / CSS keywords | `prompt` | `--domain prompt "minimalism"` |
### Step 4: Stack Guidelines (match your framework)
Get implementation-specific best practices for the stack you're building in.
Pass the `--stack` that matches the project's framework:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack <your-stack>
# e.g. --stack nextjs | react | shadcn | vue | svelte | astro | swiftui | flutter | react-native
```
---
## Search Reference
### Available Domains
| Domain | Use For | Example Keywords |
|--------|---------|------------------|
| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
| `gsap` | GSAP animation skeletons by intensity tier | scroll reveal, stagger, magnetic cursor, page transition |
| `google-fonts` | Individual Google Fonts lookup | sans serif, monospace, japanese, variable font, popular |
| `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache |
| `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type |
| `prompt` | AI prompts, CSS keywords | (style name) |
### Available Stacks
Run `ls <skill>/data/stacks/` to see the live set. Shipped stacks:
| Stack | Focus |
|-------|-------|
| `react` | Components, hooks, render performance |
| `nextjs` | App Router, RSC, Server Actions, rendering |
| `vue` | Components, Composition API, reactivity |
| `nuxtjs` | Nuxt app patterns, SSR data fetching |
| `nuxt-ui` | Nuxt UI component patterns |
| `svelte` | Components, stores, transitions |
| `astro` | Islands, content, partial hydration |
| `shadcn` | shadcn/ui primitives, composition |
| `html-tailwind` | Tailwind utility patterns |
| `angular` | Components, signals, services |
| `laravel` | Blade / server-rendered UI patterns |
| `swiftui` | Views, state, navigation (iOS/macOS) |
| `flutter` | Widgets, state, navigation |
| `jetpack-compose` | Composables, state, navigation (Android) |
| `react-native` | Components, Navigation, Lists |
| `threejs` | 3D scenes, materials, performance |
---
## Example Workflow
**User request:** "Make an AI search homepage."
### Step 1: Analyze Requirements
- Product type: Tool (AI search engine)
- Target audience: C-end users looking for fast, intelligent search
- Style keywords: modern, minimal, content-first, dark mode
- Stack: Next.js (a homepage is a web surface; use a web `--stack`)
### Step 2: Generate Design System (REQUIRED)
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "AI search tool modern minimal" --design-system -p "AI Search"
```
**Output:** Complete design system with pattern, style, colors, typography, effects, and anti-patterns.
### Step 3: Supplement with Detailed Searches (as needed)
```bash
# Get style options for a modern tool product
python3 skills/ui-ux-pro-max/scripts/search.py "minimalism dark mode" --domain style
# Get UX best practices for search interaction and loading
python3 skills/ui-ux-pro-max/scripts/search.py "search loading animation" --domain ux
```
### Step 4: Stack Guidelines
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack nextjs
```
**Then:** Synthesize design system + detailed searches and implement the design.
---
## Output Formats
The `--design-system` flag supports two output formats:
```bash
# ASCII box (default) - best for terminal display
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system
# Markdown - best for documentation
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown
```
---
## Tips for Better Results
### Query Strategy
- Use **multi-dimensional keywords** — combine product + industry + tone + density: `"entertainment social vibrant content-dense"` not just `"app"`
- Try different keywords for the same need: `"playful neon"``"vibrant dark"``"content-first minimal"`
- Use `--design-system` first for full recommendations, then `--domain` to deep-dive any dimension you're unsure about
- Add the `--stack` that matches the project's framework for implementation-specific guidance
### Common Sticking Points
| Problem | What to Do |
|---------|------------|
| Can't decide on style/color | Re-run `--design-system` with different keywords |
| Dark mode contrast issues | Quick Reference §6: `color-dark-mode` + `color-accessible-pairs` |
| Animations feel unnatural | Quick Reference §7: `spring-physics` + `easing` + `exit-faster-than-enter` |
| Form UX is poor | Quick Reference §8: `inline-validation` + `error-clarity` + `focus-management` |
| Navigation feels confusing | Quick Reference §9: `nav-hierarchy` + `bottom-nav-limit` + `back-behavior` |
| Layout breaks on small screens | Quick Reference §5: `mobile-first` + `breakpoint-consistency` |
| Performance / jank | Quick Reference §3: `virtualize-lists` + `main-thread-budget` + `debounce-throttle` |
### Pre-Delivery Checklist
- Run `--domain ux "animation accessibility z-index loading"` as a UX validation pass before implementation
- Run through Quick Reference **§1§3** (CRITICAL + HIGH) as a final review
- Test on 375px (small phone) and landscape orientation
- Verify behavior with **reduced-motion** enabled and **Dynamic Type** at largest size
- Check dark mode contrast independently (don't assume light mode values work)
- Confirm all touch targets ≥44pt and no content hidden behind safe areas
---
## Common Rules for Professional UI
These are frequently overlooked issues that make UI look unprofessional:
Scope notice: The rules below are for App UI (iOS/Android/React Native/Flutter), not desktop-web interaction patterns.
### Icons & Visual Elements
| Rule | Standard | Avoid | Why It Matters |
|------|----------|--------|----------------|
| **No Emoji as Structural Icons** | Use vector-based icons (e.g., Lucide, react-native-vector-icons, @expo/vector-icons). | Using emojis (🎨 🚀 ⚙️) for navigation, settings, or system controls. | Emojis are font-dependent, inconsistent across platforms, and cannot be controlled via design tokens. |
| **Vector-Only Assets** | Use SVG or platform vector icons that scale cleanly and support theming. | Raster PNG icons that blur or pixelate. | Ensures scalability, crisp rendering, and dark/light mode adaptability. |
| **Stable Interaction States** | Use color, opacity, or elevation transitions for press states without changing layout bounds. | Layout-shifting transforms that move surrounding content or trigger visual jitter. | Prevents unstable interactions and preserves smooth motion/perceived quality on mobile. |
| **Correct Brand Logos** | Use official brand assets and follow their usage guidelines (spacing, color, clear space). | Guessing logo paths, recoloring unofficially, or modifying proportions. | Prevents brand misuse and ensures legal/platform compliance. |
| **Consistent Icon Sizing** | Define icon sizes as design tokens (e.g., icon-sm, icon-md = 24pt, icon-lg). | Mixing arbitrary values like 20pt / 24pt / 28pt randomly. | Maintains rhythm and visual hierarchy across the interface. |
| **Stroke Consistency** | Use a consistent stroke width within the same visual layer (e.g., 1.5px or 2px). | Mixing thick and thin stroke styles arbitrarily. | Inconsistent strokes reduce perceived polish and cohesion. |
| **Filled vs Outline Discipline** | Use one icon style per hierarchy level. | Mixing filled and outline icons at the same hierarchy level. | Maintains semantic clarity and stylistic coherence. |
| **Touch Target Minimum** | Minimum 44×44pt interactive area (use hitSlop if icon is smaller). | Small icons without expanded tap area. | Meets accessibility and platform usability standards. |
| **Icon Alignment** | Align icons to text baseline and maintain consistent padding. | Misaligned icons or inconsistent spacing around them. | Prevents subtle visual imbalance that reduces perceived quality. |
| **Icon Contrast** | Follow WCAG contrast standards: 4.5:1 for small elements, 3:1 minimum for larger UI glyphs. | Low-contrast icons that blend into the background. | Ensures accessibility in both light and dark modes. |
### Interaction (App)
| Rule | Do | Don't |
|------|----|----- |
| **Tap feedback** | Provide clear pressed feedback (ripple/opacity/elevation) within 80-150ms | No visual response on tap |
| **Animation timing** | Keep micro-interactions around 150-300ms with platform-native easing | Instant transitions or slow animations (>500ms) |
| **Accessibility focus** | Ensure screen reader focus order matches visual order and labels are descriptive | Unlabeled controls or confusing focus traversal |
| **Disabled state clarity** | Use disabled semantics (`disabled`/native disabled props), reduced emphasis, and no tap action | Controls that look tappable but do nothing |
| **Touch target minimum** | Keep tap areas >=44x44pt (iOS) or >=48x48dp (Android), expand hit area when icon is smaller | Tiny tap targets or icon-only hit areas without padding |
| **Gesture conflict prevention** | Keep one primary gesture per region and avoid nested tap/drag conflicts | Overlapping gestures causing accidental actions |
| **Semantic native controls** | Prefer native interactive primitives (`Button`, `Pressable`, platform equivalents) with proper accessibility roles | Generic containers used as primary controls without semantics |
### Light/Dark Mode Contrast
| Rule | Do | Don't |
|------|----|----- |
| **Surface readability (light)** | Keep cards/surfaces clearly separated from background with sufficient opacity/elevation | Overly transparent surfaces that blur hierarchy |
| **Text contrast (light)** | Maintain body text contrast >=4.5:1 against light surfaces | Low-contrast gray body text |
| **Text contrast (dark)** | Maintain primary text contrast >=4.5:1 and secondary text >=3:1 on dark surfaces | Dark mode text that blends into background |
| **Border and divider visibility** | Ensure separators are visible in both themes (not just light mode) | Theme-specific borders disappearing in one mode |
| **State contrast parity** | Keep pressed/focused/disabled states equally distinguishable in light and dark themes | Defining interaction states for one theme only |
| **Token-driven theming** | Use semantic color tokens mapped per theme across app surfaces/text/icons | Hardcoded per-screen hex values |
| **Scrim and modal legibility** | Use a modal scrim strong enough to isolate foreground content (typically 40-60% black) | Weak scrim that leaves background visually competing |
### Layout & Spacing
| Rule | Do | Don't |
|------|----|----- |
| **Safe-area compliance** | Respect top/bottom safe areas for all fixed headers, tab bars, and CTA bars | Placing fixed UI under notch, status bar, or gesture area |
| **System bar clearance** | Add spacing for status/navigation bars and gesture home indicator | Let tappable content collide with OS chrome |
| **Consistent content width** | Keep predictable content width per device class (phone/tablet) | Mixing arbitrary widths between screens |
| **8dp spacing rhythm** | Use a consistent 4/8dp spacing system for padding/gaps/section spacing | Random spacing increments with no rhythm |
| **Readable text measure** | Keep long-form text readable on large devices (avoid edge-to-edge paragraphs on tablets) | Full-width long text that hurts readability |
| **Section spacing hierarchy** | Define clear vertical rhythm tiers (e.g., 16/24/32/48) by hierarchy | Similar UI levels with inconsistent spacing |
| **Adaptive gutters by breakpoint** | Increase horizontal insets on larger widths and in landscape | Same narrow gutter on all device sizes/orientations |
| **Scroll and fixed element coexistence** | Add bottom/top content insets so lists are not hidden behind fixed bars | Scroll content obscured by sticky headers/footers |
---
## Pre-Delivery Checklist
Before delivering UI code, verify these items:
Scope notice: This checklist is for App UI (iOS/Android/React Native/Flutter).
### Visual Quality
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons come from a consistent icon family and style
- [ ] Official brand assets are used with correct proportions and clear space
- [ ] Pressed-state visuals do not shift layout bounds or cause jitter
- [ ] Semantic theme tokens are used consistently (no ad-hoc per-screen hardcoded colors)
### Interaction
- [ ] All tappable elements provide clear pressed feedback (ripple/opacity/elevation)
- [ ] Touch targets meet minimum size (>=44x44pt iOS, >=48x48dp Android)
- [ ] Micro-interaction timing stays in the 150-300ms range with native-feeling easing
- [ ] Disabled states are visually clear and non-interactive
- [ ] Screen reader focus order matches visual order, and interactive labels are descriptive
- [ ] Gesture regions avoid nested/conflicting interactions (tap/drag/back-swipe conflicts)
### Light/Dark Mode
- [ ] Primary text contrast >=4.5:1 in both light and dark mode
- [ ] Secondary text contrast >=3:1 in both light and dark mode
- [ ] Dividers/borders and interaction states are distinguishable in both modes
- [ ] Modal/drawer scrim opacity is strong enough to preserve foreground legibility (typically 40-60% black)
- [ ] Both themes are tested before delivery (not inferred from a single theme)
### Layout
- [ ] Safe areas are respected for headers, tab bars, and bottom CTA bars
- [ ] Scroll content is not hidden behind fixed/sticky bars
- [ ] Verified on small phone, large phone, and tablet (portrait + landscape)
- [ ] Horizontal insets/gutters adapt correctly by device size and orientation
- [ ] 4/8dp spacing rhythm is maintained across component, section, and page levels
- [ ] Long-form text measure remains readable on larger devices (no edge-to-edge paragraphs)
### Accessibility
- [ ] All meaningful images/icons have accessibility labels
- [ ] Form fields have labels, hints, and clear error messages
- [ ] Color is not the only indicator
- [ ] Reduced motion and dynamic text size are supported without layout breakage
- [ ] Accessibility traits/roles/states (selected, disabled, expanded) are announced correctly
---
_Imported into astroagent from **ui-ux-pro-max-skill** by nextlevelbuilder_
_(github.com/nextlevelbuilder/ui-ux-pro-max-skill). The searchable CSV/script_
_engine is omitted (no shell in the sandbox); this is the guidance layer._

1
app/.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
ko_fi: andreialba

24
app/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
.astro
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
app/.prettierignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
.astro
pnpm-lock.yaml
package-lock.json

6
app/.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

241
app/AGENTS.md Normal file
View file

@ -0,0 +1,241 @@
# Instructions for Astro Theme Development
These instructions apply to all Astro theme work. Prioritize clean, reusable, accessible, fast, SEO-friendly code. Treat the theme as something that may be reused across multiple websites, not as a one-off implementation.
## General Principles
* Prefer simple, maintainable Astro components over unnecessary abstractions.
* Keep the default Astro advantage: mostly static HTML, minimal JavaScript, and hydration only where needed.
* Do not add client-side JavaScript unless there is a clear user-facing reason.
* Avoid unnecessary dependencies. Before adding a package, check whether the same result can be achieved with Astro, HTML, CSS, or a small utility.
* Keep components reusable, documented, and easy to override.
* Use TypeScript where helpful, especially for props, content schemas, config objects, and reusable utilities.
* Favor progressive enhancement. The site should remain usable even if JavaScript fails.
* Keep markup clean, semantic, and easy to crawl.
* Never solve layout or behavior problems in a way that harms accessibility, SEO, or performance.
* Follow README layout as here https://github.com/andreialba/maria must include the title, preview image with a link to the preview URL, those cards with versions, preview link, short description of the theme, list of features, and how to set things up.
* Add MIT license under Andrei Alba
## Astro-Specific Guidelines
* Use `.astro` components for static and content-focused UI.
* Use islands/client hydration only when interactivity is required.
* Avoid `client:load` unless the component truly needs to run immediately.
* Prefer `client:visible`, `client:idle`, or no hydration when possible.
* Keep layout components responsible for page structure, shared metadata, global slots, and theme-level wrappers.
* Keep UI components small and focused.
* Use `Astro.props` with typed props where possible.
* Use content collections for structured content like posts, pages, projects, docs, testimonials, FAQs, and changelogs.
* Validate frontmatter with schemas instead of relying on loose optional fields.
* Keep route structure clean and predictable.
* Do not hardcode production URLs inside components. Use site config, constants, or environment-aware helpers.
* Make sure the theme works with a configurable `site` value in `astro.config.*`.
## Accessibility Requirements
* Use semantic HTML first. Do not use ARIA when a native HTML element solves the problem.
* Use landmarks properly: `header`, `nav`, `main`, `section`, `article`, `aside`, and `footer` where appropriate.
* Each page should have one clear `h1`.
* Preserve logical heading order. Do not skip heading levels for visual styling.
* All interactive elements must be keyboard accessible.
* Use real buttons for actions and real links for navigation.
* Every form control must have an associated label.
* Inputs, errors, help text, and validation states must be understandable to screen readers.
* Add visible focus styles. Never remove outlines without replacing them with an accessible focus state.
* Provide a skip link for keyboard users when the layout has repeated navigation.
* Use descriptive link text. Avoid vague text like “click here” or “read more” without context.
* Images must have useful `alt` text when meaningful.
* Decorative images should use empty alt text.
* Icons used as buttons or links must have accessible names.
* Ensure sufficient color contrast for text, icons, borders, and states.
* Do not rely on color alone to communicate meaning.
* Respect `prefers-reduced-motion`.
* Avoid auto-playing motion, carousels, or animations unless they are user-controlled and accessible.
* Modals, menus, accordions, tabs, dropdowns, and mobile navigation must handle focus, keyboard interaction, and escape/close behavior correctly.
* Test important templates with keyboard navigation and screen reader-friendly markup in mind.
## SEO Requirements
* Every page should have a unique, descriptive `<title>`.
* Every indexable page should have a useful meta description.
* Use a reusable SEO or Head component for metadata.
* Include canonical URLs where appropriate.
* Support Open Graph metadata for social sharing.
* Support Twitter/X card metadata where appropriate.
* Use absolute URLs for canonical and social image URLs.
* Configure `site` in `astro.config.*` so canonical URLs and sitemap generation work correctly.
* Include sitemap support for production themes.
* Include sensible robots handling.
* Avoid duplicate metadata across pages.
* Avoid duplicate content caused by inconsistent trailing slashes, canonical paths, or pagination.
* Use clean, descriptive URLs.
* Add structured data where useful, such as `WebSite`, `Organization`, `Article`, `BreadcrumbList`, `Product`, `FAQPage`, or `LocalBusiness`, depending on the theme.
* Do not add fake schema data. Structured data must match visible page content.
* Use proper heading structure to reflect the content hierarchy.
* Ensure important content is present in the HTML, not hidden behind client-only rendering.
* Use descriptive image filenames where possible.
* Add alt text and dimensions for content images.
* Include pagination metadata where relevant.
* Support multilingual SEO only when the theme actually supports multiple languages. If it does, include proper `lang`, canonical, and alternate/hreflang handling.
* Keep internal links crawlable with real `<a href="">` links.
* Avoid JavaScript-only navigation for normal pages.
## Performance Requirements
* Keep JavaScript minimal.
* Avoid shipping framework runtime code unless needed.
* Hydrate components selectively.
* Prefer static rendering where possible.
* Avoid large global scripts.
* Avoid large CSS bundles.
* Keep CSS scoped, layered, or organized in a predictable way.
* Remove unused CSS and unused components.
* Optimize images with Astros image tools where appropriate.
* Always include image width and height to reduce layout shift.
* Use responsive images for large visual assets.
* Lazy-load below-the-fold images.
* Do not lazy-load critical above-the-fold hero images unless there is a good reason.
* Use modern image formats when appropriate.
* Avoid layout shifts from images, ads, embeds, cookie banners, and late-loading UI.
* Keep third-party scripts optional and documented.
* Load analytics, embeds, chat widgets, and marketing scripts only when explicitly enabled.
* Avoid blocking render with unnecessary scripts or styles.
* Keep Core Web Vitals in mind, especially LCP, CLS, and INP.
## Font Optimization
* Prefer self-hosted fonts for production themes.
* Use only the font families actually needed by the theme.
* Include only the font weights and styles actually used.
* Prefer modern formats such as `woff2`.
* Use `font-display: swap` or another intentional rendering strategy.
* Preload only critical fonts used above the fold.
* Do not preload every font file.
* Define fallback font stacks that closely match the custom font metrics.
* Avoid layout shift caused by late-loading fonts.
* Do not load fonts from external providers by default unless the user explicitly chooses that option.
* Keep font configuration centralized so users can replace or disable custom fonts easily.
## CSS and Design System Guidelines
* Use design tokens or CSS custom properties for colors, spacing, typography, radii, shadows, and layout values.
* Keep theme customization simple.
* Avoid scattering hardcoded colors and spacing values throughout components.
* Support light and dark modes only if the theme is designed for both.
* Respect user system preference when dark mode is supported.
* Ensure color tokens meet accessibility contrast requirements.
* Keep responsive behavior consistent across components.
* Use fluid and responsive typography where appropriate.
* Avoid unnecessary wrappers and deeply nested markup.
* Keep animations subtle, optional, and respectful of reduced-motion preferences.
## Content and Markdown Guidelines
* Content should be easy to manage through Markdown, MDX, or content collections.
* Validate required frontmatter fields.
* Provide sensible defaults for optional metadata.
* Avoid requiring users to duplicate the same SEO fields in many places when defaults can be generated safely.
* Support draft or unpublished content only when the theme explicitly needs it.
* Make dates, authors, categories, tags, and excerpts consistent.
* Make sure generated archive, tag, category, author, and pagination pages have useful metadata.
* Avoid rendering empty UI sections when content is missing.
## Image and Media Guidelines
* Use optimized local images where possible.
* Provide responsive sizes for theme-controlled images.
* Include `alt` text fields in content schemas where images are user-provided.
* Do not use background images for meaningful content unless an accessible text alternative exists.
* Avoid enormous default hero images.
* Provide predictable aspect ratios to prevent layout shift.
* Lazy-load media that is not immediately visible.
* Make video/audio embeds accessible with labels, captions, transcripts, or surrounding explanatory content when relevant.
## Component Guidelines
* Components should have clear responsibilities.
* Props should be typed and documented when not obvious.
* Use sensible defaults.
* Avoid components that silently fail or render broken markup when required props are missing.
* Avoid coupling generic components to one specific page.
* Keep class names predictable.
* Make components easy to copy, remove, or override.
* Do not introduce global side effects from small components.
* For interactive components, document keyboard behavior and accessibility expectations.
## Forms
* Use semantic form markup.
* Every input must have a label.
* Required fields must be indicated accessibly.
* Error messages must be connected to the relevant fields.
* Success and error states should be announced or clearly visible.
* Do not rely only on placeholder text as a label.
* Use appropriate input types such as `email`, `tel`, `url`, `search`, and `number`.
* Keep forms usable without unnecessary JavaScript where possible.
* Do not include a form provider by default unless it is configurable.
## Navigation
* Use real links for navigation.
* Mark the current page or section when possible.
* Ensure mobile navigation works with keyboard and screen readers.
* Trap focus only when appropriate, such as inside an open modal menu.
* Restore focus after closing menus or dialogs when relevant.
* Make dropdowns and submenus accessible.
* Do not hide navigation from assistive technology unless it is truly inactive.
## Build, Config, and DX
* Keep configuration centralized and documented.
* Provide clear theme constants for site name, default title, description, social links, navigation, and footer data.
* Avoid requiring users to edit many files for common changes.
* Use environment variables only where they are actually needed.
* Do not expose secrets in client-side code.
* Keep the README accurate.
* Include setup, development, build, preview, customization, and deployment instructions.
* Add comments only where they clarify non-obvious decisions.
* Keep generated examples realistic and production-friendly.
* Make sure the theme builds cleanly without warnings or broken links.
## Testing and QA Checklist
Before considering work complete, verify:
* The project builds successfully.
* Pages render without console errors.
* No unnecessary client JavaScript is shipped.
* Navigation works with keyboard only.
* Focus states are visible.
* Forms have labels and accessible states.
* Images have correct alt text and dimensions.
* Metadata is present and unique per page.
* Canonical URLs are correct.
* Sitemap generation works.
* Social preview metadata is valid.
* The layout is responsive.
* Dark mode works if supported.
* Reduced motion is respected.
* Lighthouse or similar checks do not reveal obvious accessibility, SEO, or performance issues.
* There are no broken internal links.
* There is no placeholder content left in production-facing defaults.
## Things to Avoid
* Do not use `<div>` and `<span>` for everything when semantic HTML exists.
* Do not add ARIA roles to elements that already have correct native semantics.
* Do not remove focus outlines without accessible replacements.
* Do not add heavy animation libraries for simple transitions.
* Do not add global JavaScript for isolated UI behavior.
* Do not load all font weights “just in case.”
* Do not load external fonts by default.
* Do not use client-only rendering for content that should be crawlable.
* Do not hide important content behind JavaScript.
* Do not hardcode metadata across every page.
* Do not ship large demo assets as required production assets.
* Do not introduce dependencies without a clear reason.
* Do not sacrifice accessibility for visual polish.
## Preferred Outcome
The final Astro theme should be fast, accessible, SEO-ready, easy to customize, and pleasant to maintain. It should provide strong defaults while staying lightweight and flexible.

20
app/CHANGELOG.md Normal file
View file

@ -0,0 +1,20 @@
# Changelog
All notable changes to Quiet Pages will be documented in this file.
## [1.0.0] - 2026-06-19
### Added
- Initial public release of Quiet Pages, an Astro magazine theme for essays, field notes, blogs, and long-form editorial sites.
- Editorial homepage with a full-bleed visual lead story, featured post section, latest posts, and newsletter CTA.
- MDX blog posts powered by Astro content collections with validated frontmatter.
- Blog archive with client-side search, category filters, tag filters, and load-more pagination.
- Category, tag, and author archive pages.
- Article pages with breadcrumbs, table of contents, featured image captions, sharing links, author cards, related posts, and previous/next navigation.
- RSS feed, XML sitemap, and dynamic robots.txt route.
- SEO defaults including canonical URLs, Open Graph metadata, Twitter card metadata, and article JSON-LD.
- Light and dark mode with system preference support.
- Self-hosted Inter, Fraunces, and JetBrains Mono fonts.
- Responsive images through Astro's image pipeline.
- Accessibility defaults including semantic landmarks, skip link, visible focus styles, current-page navigation state, keyboard-friendly search/menu controls, and reduced-motion handling.

21
app/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andrei Alba
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

139
app/README.md Normal file
View file

@ -0,0 +1,139 @@
# QuietPages - Astro Magazine Theme
[![Quiet Pages theme preview](./preview.webp)](https://quietpages-eta.vercel.app/)
![Astro 6](https://img.shields.io/badge/Astro-6-ff5d01?style=for-the-badge&logo=astro&logoColor=white)
![Tailwind CSS 4](https://img.shields.io/badge/Tailwind_CSS-4-38bdf8?style=for-the-badge&logo=tailwindcss&logoColor=white)
![MDX](https://img.shields.io/badge/MDX-enabled-1b1f24?style=for-the-badge&logo=mdx&logoColor=white)
![License MIT](https://img.shields.io/badge/License-MIT-111827?style=for-the-badge)
Preview: [quietpages-eta.vercel.app](https://quietpages-eta.vercel.app/)
QuietPages is a calm Astro theme for independent magazines, personal journals, and long-form editorial sites. It keeps the reading experience simple and fast while including the pieces a production-ready publication needs: archives, taxonomy pages, author pages, RSS, sitemap, structured metadata, and self-hosted fonts.
## Features
- Editorial homepage with a full-bleed visual lead story
- Blog archive with search, category filters, tag filters, and load-more pagination
- MDX blog posts powered by Astro content collections
- Category, tag, and author index pages
- Article pages with breadcrumbs, table of contents, sharing actions, related posts, and adjacent navigation
- RSS feed, XML sitemap, and dynamic robots.txt
- Canonical URLs, Open Graph tags, Twitter card metadata, and article JSON-LD
- Light and dark modes with system preference support
- Self-hosted Inter, Fraunces, and JetBrains Mono fonts
- Accessible landmarks, visible focus states, skip link, and reduced-motion handling
- Responsive images through Astro's image pipeline
- Contact page and custom 404 page
## Tech Stack
- Astro 6
- Tailwind CSS 4 via the Vite plugin
- MDX
- Astro content collections
- Self-hosted `woff2` fonts
## Getting Started
Install dependencies:
```bash
npm install
```
Start the development server:
```bash
npm run dev
```
Build for production:
```bash
npm run build
```
Preview the production build locally:
```bash
npm run preview
```
## Theme Setup
The main theme settings live in [`src/lib/blog-data.js`](./src/lib/blog-data.js):
- `SITE.name`
- `SITE.description`
- `SITE.url`
- navigation-adjacent data such as authors, categories, and tags
Set your production URL before deploying:
```bash
SITE_URL=https://your-domain.com
```
You can also use:
```bash
PUBLIC_SITE_URL=https://your-domain.com
```
This keeps canonical URLs, Open Graph URLs, RSS links, robots.txt, and the sitemap aligned with the deployed domain.
## Content
Blog posts live in [`src/content/blog`](./src/content/blog). Each post uses an `index.mdx` file inside its own folder, with local images stored beside the content.
Required frontmatter is validated in [`src/content.config.js`](./src/content.config.js):
- `title`
- `excerpt`
- `date`
- `readingTime`
- `category`
- `tags`
- `author`
- `thumbnail`
## SEO
QuietPages includes:
- unique page titles and descriptions
- canonical URLs generated from the configured site URL
- Open Graph and Twitter card metadata
- article JSON-LD on post pages
- XML sitemap at `/sitemap.xml`
- RSS feed at `/rss.xml`
- robots.txt with a sitemap reference
Main SEO files:
- [`src/layouts/BaseLayout.astro`](./src/layouts/BaseLayout.astro)
- [`src/pages/sitemap.xml.js`](./src/pages/sitemap.xml.js)
- [`src/pages/robots.txt.js`](./src/pages/robots.txt.js)
- [`src/pages/rss.xml.js`](./src/pages/rss.xml.js)
## Images and Assets
The repository includes [`preview.webp`](./preview.webp) for the README preview. Content images live beside each MDX post, and shared theme assets live in [`src/assets`](./src/assets).
Fonts are self-hosted in [`public/fonts`](./public/fonts). Replace those files and the `@font-face` declarations in [`src/styles.css`](./src/styles.css) if you want a different type system.
## Customization
- Edit theme colors, typography tokens, radii, and prose styles in [`src/styles.css`](./src/styles.css).
- Update authors, categories, tags, and site defaults in [`src/lib/blog-data.js`](./src/lib/blog-data.js).
- Add or remove navigation items in [`src/components/Header.astro`](./src/components/Header.astro) and [`src/components/Footer.astro`](./src/components/Footer.astro).
- Replace example posts in [`src/content/blog`](./src/content/blog) with your own MDX content.
## Deployment
QuietPages works anywhere Astro can deploy. For Vercel, Netlify, or another static host, set `SITE_URL` to the production domain before building so metadata and feeds use absolute URLs.
## License
This project is licensed under the [MIT License](./LICENSE).

23
app/astro.config.mjs Normal file
View file

@ -0,0 +1,23 @@
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import tailwindcss from "@tailwindcss/vite";
const site =
process.env.SITE_URL || process.env.PUBLIC_SITE_URL || "https://comiida.com";
export default defineConfig({
site,
// astroagent overrides outDir for isolated preview builds (PREVIEW_OUT);
// falls back to the live output for normal/cron builds.
outDir: process.env.PREVIEW_OUT || "../public",
// For previews, PREVIEW_BASE prefixes asset paths (/_preview/<id>) so the
// preview is self-contained and doesn't pull _astro/fonts from the live site.
base: process.env.PREVIEW_BASE || undefined,
integrations: [mdx()],
vite: {
plugins: [tailwindcss()],
build: {
emptyOutDir: false,
},
},
});

6201
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
app/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "quiet-pages",
"version": "1.0.0",
"description": "A quiet Astro magazine theme for essays, field notes, and long-form writing.",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"format": "prettier --write ."
},
"dependencies": {
"@astrojs/mdx": "^6.0.3",
"@tailwindcss/vite": "^4.3.1",
"astro": "^6.4.7",
"tailwindcss": "^4.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"prettier": "^3.8.4",
"vite": "^7.3.5"
}
}

BIN
app/preview.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -0,0 +1,20 @@
---
import Icon from "./Icon.astro";
const { items = [] } = Astro.props;
---
<nav aria-label="Breadcrumb" class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
{
items.map((item, index) => (
<span class="flex items-center gap-1">
{index > 0 && <Icon name="chevron-right" class="h-3 w-3" />}
{item.to ? (
<a href={item.to} class="hover:text-foreground">{item.label}</a>
) : (
<span class="text-foreground">{item.label}</span>
)}
</span>
))
}
</nav>

View file

@ -0,0 +1,221 @@
---
/**
* astroagent in-site console drawer — a multi-turn chat.
* Inject once in the base layout (before </body>). Ships to every visitor but
* stays hidden/inert unless /devconsole/ping reports the visitor is authed
* (token cookie). Real security is server-side on every endpoint; the DOM
* hiding is only UX. Unlock once by visiting any page with ?devkey=<TOKEN>.
*/
const route = "/devconsole";
---
<div id="aa-console" data-route={route} hidden>
<button id="aa-handle" type="button" aria-label="Open developer console">▲ astroagent</button>
<section id="aa-panel" hidden aria-label="Developer console">
<header>
<span class="aa-title">astroagent</span>
<span id="aa-state" class="aa-state"></span>
<button id="aa-key" type="button" aria-label="Agent auth token" title="Set agent auth token">🔑</button>
<button id="aa-new" type="button" aria-label="New conversation" title="New conversation (clear)">✚</button>
<button id="aa-lock" type="button" aria-label="Lock console" title="Lock (hide until next devkey)">🔒</button>
<button id="aa-close" type="button" aria-label="Collapse">▼</button>
</header>
<div id="aa-chat" aria-live="polite"></div>
<div id="aa-actions" hidden>
<a id="aa-preview" href="#" target="_blank" rel="noopener">Open preview ↗</a>
<button id="aa-publish" type="button">Publish</button>
</div>
<form id="aa-form">
<textarea id="aa-prompt" rows="2" placeholder="Ask or change anything — “what pages do I have?”, “make the buttons green”, then “now make them bigger”"></textarea>
<button id="aa-run" type="submit">Send</button>
</form>
</section>
<div id="aa-authmodal" hidden>
<div class="aa-modal">
<div class="aa-modal-h">Agent sign-in required</div>
<p class="aa-modal-p">The agent's Claude session was lost. Paste a long-lived token — generate one on the server with <code>claude setup-token</code>.</p>
<input id="aa-authinput" type="password" placeholder="Paste auth token" autocomplete="off" spellcheck="false" />
<div id="aa-authmsg" class="aa-modal-msg"></div>
<div class="aa-modal-btns">
<button id="aa-authcancel" type="button">Cancel</button>
<button id="aa-authsave" type="button">Save &amp; verify</button>
</div>
</div>
</div>
</div>
<style>
#aa-console { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
#aa-handle { background:#111; color:#e6e6e6; border:1px solid #333; border-radius:8px; padding:8px 12px; font-size:12px; cursor:pointer; box-shadow:0 4px 16px rgba(0,0,0,.3); }
#aa-handle:hover { background:#1b1b1b; }
#aa-panel { width: min(700px, calc(100vw - 32px)); height: min(64vh, 560px); background:#0c0c0d; color:#d6d6d6; border:1px solid #2a2a2a; border-radius:10px; display:flex; flex-direction:column; box-shadow:0 12px 40px rgba(0,0,0,.5); overflow:hidden; }
#aa-panel header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#141416; border-bottom:1px solid #2a2a2a; font-size:12px; }
#aa-panel .aa-title { font-weight:600; color:#fff; }
#aa-panel .aa-state { margin-left:auto; color:#8a8a8a; font-size:11px; }
#aa-close, #aa-lock, #aa-key, #aa-new { background:none; border:none; color:#8a8a8a; cursor:pointer; font-size:12px; padding:2px 4px; }
#aa-lock:hover, #aa-close:hover, #aa-key:hover, #aa-new:hover { color:#fff; }
#aa-chat { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:8px; }
.aa-msg { max-width:86%; padding:8px 11px; border-radius:9px; font-size:12px; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.aa-user { align-self:flex-end; background:rgba(31,111,235,.22); border:1px solid rgba(31,111,235,.45); color:#dbeafe; }
.aa-bot { align-self:flex-start; background:#161b22; border:1px solid #2a2a2a; color:#d6d6d6; }
.aa-bot .aa-tool { display:block; color:#6cb6ff; opacity:.75; font-size:11px; }
.aa-bot .aa-txt { display:block; margin:2px 0; }
.aa-bot .aa-emsg { color:#ff7b72; }
.aa-sys { align-self:center; color:#8a8a8a; font-size:11px; }
.aa-sys.ok { color:#7ee787; }
#aa-actions { display:flex; gap:8px; align-items:center; padding:8px 10px; border-top:1px solid #2a2a2a; }
#aa-actions a { color:#6cb6ff; font-size:12px; text-decoration:none; margin-right:auto; }
#aa-form { display:flex; gap:8px; padding:10px; border-top:1px solid #2a2a2a; }
#aa-prompt { flex:1; resize:none; background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:8px; font:inherit; font-size:12px; }
#aa-panel button:not(#aa-close):not(#aa-lock):not(#aa-key):not(#aa-new) { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
#aa-panel button:disabled { opacity:.5; cursor:default; }
#aa-authmodal { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:2147483001; }
#aa-authmodal .aa-modal { background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:10px; padding:18px; width:min(440px, calc(100vw - 32px)); box-shadow:0 12px 40px rgba(0,0,0,.6); }
.aa-modal-h { font-weight:600; color:#fff; margin-bottom:6px; }
.aa-modal-p { font-size:12px; color:#9a9a9a; margin:0 0 12px; line-height:1.5; }
.aa-modal-p code { color:#6cb6ff; }
#aa-authinput { width:100%; box-sizing:border-box; background:#0c0c0d; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:9px; font:inherit; font-size:12px; }
.aa-modal-msg { font-size:12px; min-height:16px; margin:8px 0; }
.aa-modal-btns { display:flex; gap:8px; justify-content:flex-end; }
#aa-authsave { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
#aa-authcancel { background:#30363d !important; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
</style>
<script>
const root = document.getElementById("aa-console");
const route = root.dataset.route;
const $ = (id) => document.getElementById(id);
const api = (p) => route + p;
async function boot() {
const devkey = new URLSearchParams(location.search).get("devkey");
let authed = false;
try {
const r = await fetch(api("/ping") + (devkey ? "?key=" + encodeURIComponent(devkey) : ""), { credentials: "same-origin" });
authed = (await r.json()).authed;
} catch { authed = false; }
if (!authed) { root.remove(); return; }
root.hidden = false;
wire();
}
function wire() {
const handle = $("aa-handle"), panel = $("aa-panel");
const chat = $("aa-chat"), stateEl = $("aa-state"), actions = $("aa-actions");
const previewLink = $("aa-preview"), runBtn = $("aa-run"), promptEl = $("aa-prompt");
let convId = null, es = null, bot = null, running = false;
const open = () => { panel.hidden = false; handle.hidden = true; promptEl.focus(); };
const close = () => { panel.hidden = true; handle.hidden = false; };
handle.onclick = open; $("aa-close").onclick = close;
$("aa-lock").onclick = async () => {
try { await fetch(api("/logout"), { method:"POST", credentials:"same-origin" }); } catch {}
root.remove();
};
const scroll = () => { chat.scrollTop = chat.scrollHeight; };
const setState = (s) => (stateEl.textContent = s || "");
const busy = (b) => { running = b; runBtn.disabled = b; };
function bubble(cls) { const d = document.createElement("div"); d.className = "aa-msg " + cls; chat.appendChild(d); scroll(); return d; }
function userMsg(t) { const d = bubble("aa-user"); d.textContent = t; }
function sysMsg(t, ok) { const d = bubble("aa-sys" + (ok ? " ok" : "")); d.textContent = t; }
function botStart() { bot = bubble("aa-bot"); return bot; }
function botText(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-txt"; s.textContent = t; bot.appendChild(s); scroll(); }
function botTool(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-tool"; s.textContent = "· " + t; bot.appendChild(s); scroll(); }
function botErr(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-txt aa-emsg"; s.textContent = "✗ " + t; bot.appendChild(s); scroll(); }
function ensureStream() {
if (es || !convId) return;
es = new EventSource(api("/stream") + "?conversationId=" + encodeURIComponent(convId));
es.onmessage = (m) => {
let ev; try { ev = JSON.parse(m.data); } catch { return; }
switch (ev.type) {
case "turn_start": setState("working…"); break;
case "progress":
if (ev.kind === "text") botText(ev.text);
else if (ev.kind === "tool") botTool(ev.text);
else if (ev.kind === "log") setState("building preview…");
break; // ignore 'done' (duplicate of final text)
case "preview":
previewLink.href = ev.url; actions.hidden = false;
sysMsg("✓ preview updated", true);
break;
case "turn_end": setState(""); busy(false); bot = null; break;
case "published": sysMsg("✓ published — " + (ev.commit || "").slice(0,8) + " (live)", true); actions.hidden = true; break;
case "auth_required": setState(""); busy(false); botErr("agent not signed in"); openAuth(); break;
case "error": setState(""); busy(false); botErr(ev.text || "error"); break;
case "end": es && es.close(); es = null; convId = null; busy(false); setState(""); break;
}
};
es.onerror = () => { /* keep-alive; browser auto-reconnects */ };
}
$("aa-form").onsubmit = async (e) => {
e.preventDefault();
if (running) return;
const message = promptEl.value.trim();
if (!message) return;
promptEl.value = "";
userMsg(message); botStart(); busy(true); setState("working…");
try {
const r = await fetch(api("/run"), {
method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" },
body: JSON.stringify(convId ? { conversationId: convId, message } : { message }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || "run failed");
convId = d.conversationId;
ensureStream();
} catch (err) { botErr(err.message); busy(false); setState(""); }
};
$("aa-publish").onclick = async () => {
if (!convId || running) return;
setState("publishing…");
try {
const r = await fetch(api("/publish"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || "publish failed");
} catch (err) { sysMsg("✗ " + err.message); }
setState("");
};
$("aa-new").onclick = async () => {
if (running) return;
try { await fetch(api("/discard"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) }); } catch {}
if (es) { es.close(); es = null; }
convId = null; bot = null; chat.textContent = ""; actions.hidden = true; setState("");
sysMsg("new conversation");
promptEl.focus();
};
// Enter to send, Shift+Enter for newline.
promptEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("aa-form").requestSubmit(); }
});
// --- agent auth token modal (pops up on auth loss, or via the 🔑 button) ---
const authModal = $("aa-authmodal"), authInput = $("aa-authinput"), authMsg = $("aa-authmsg");
const openAuth = () => { authMsg.textContent = ""; authInput.value = ""; authModal.hidden = false; authInput.focus(); };
const closeAuth = () => { authModal.hidden = true; };
$("aa-key").onclick = openAuth;
$("aa-authcancel").onclick = closeAuth;
$("aa-authsave").onclick = async () => {
const token = authInput.value.trim();
if (!token) return;
authMsg.style.color = "#9a9a9a"; authMsg.textContent = "verifying…";
try {
const r = await fetch(api("/auth"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ token }) });
const d = await r.json();
if (d.ok) { authMsg.style.color = "#7ee787"; authMsg.textContent = "✓ saved & verified — send your message again"; setTimeout(closeAuth, 1300); }
else { authMsg.style.color = "#ff7b72"; authMsg.textContent = "✗ " + (d.error || "failed"); }
} catch (err) { authMsg.style.color = "#ff7b72"; authMsg.textContent = "✗ " + err.message; }
};
// expose for the stream handler
window.__aaOpenAuth = openAuth;
}
boot();
</script>

View file

@ -0,0 +1,51 @@
---
import Icon from "./Icon.astro";
import { SITE, categories } from "../lib/blog-data.js";
const { flush = false } = Astro.props;
---
<footer class:list={[flush ? "mt-0" : "mt-24", "border-t border-border/60"]}>
<div class="mx-auto grid max-w-6xl gap-10 px-5 py-12 md:grid-cols-4">
<div class="md:col-span-2">
<div class="font-serif text-lg font-semibold">{SITE.name}</div>
<p class="mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">
{SITE.description}
</p>
<div class="mt-4 flex items-center gap-3 text-muted-foreground">
<a href="/rss.xml" aria-label="RSS" class="hover:text-foreground"><Icon name="rss" class="h-4 w-4" /></a>
<a href="https://x.com/hicarlosarias" aria-label="Comiida on X" class="hover:text-foreground"><Icon name="twitter" class="h-4 w-4" /></a>
</div>
</div>
<div>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Sections</div>
<ul class="mt-3 space-y-2 text-sm">
{
categories.slice(0, 5).map((category) => (
<li>
<a href={`/categories/${category.slug}`} class="text-foreground/80 hover:text-foreground">
{category.name}
</a>
</li>
))
}
</ul>
</div>
<div>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">The site</div>
<ul class="mt-3 space-y-2 text-sm">
<li><a href="/about" class="hover:text-foreground">About</a></li>
<li><a href="/faq" class="hover:text-foreground">FAQ</a></li>
<li><a href="/contact" class="hover:text-foreground">Contact</a></li>
<li><a href="/blog" class="hover:text-foreground">Archive</a></li>
<li><a href="/rss.xml" class="hover:text-foreground">RSS feed</a></li>
</ul>
</div>
</div>
<div class="border-t border-border/60">
<div class="mx-auto flex max-w-6xl flex-col items-start justify-between gap-2 px-5 py-5 text-xs text-muted-foreground sm:flex-row sm:items-center">
<div>&copy; {new Date().getFullYear()} {SITE.name}. Dining in Medellín.</div>
<div>Set in Fraunces &amp; Inter.</div>
</div>
</div>
</footer>

View file

@ -0,0 +1,200 @@
---
import Icon from "./Icon.astro";
import { SITE } from "../lib/blog-data.js";
const nav = [
{ to: "/", label: "Home" },
{ to: "/blog", label: "Writing" },
{ to: "/about", label: "About" },
{ to: "/services", label: "Services" },
{ to: "/contact", label: "Contact" },
];
const current = Astro.url.pathname.replace(/\/$/, "") || "/";
const isHome = current === "/";
const activeClass = isHome ? "home-header-link is-active" : "text-foreground";
const inactiveClass = isHome
? "home-header-link"
: "text-muted-foreground transition-colors hover:text-foreground";
const headerClass = isHome
? "sticky top-0 z-40 border-b border-transparent bg-transparent text-white"
: "sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur-md";
const iconClass = isHome
? "home-header-action inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors"
: "inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
---
<header class={headerClass} data-home-header={isHome ? "true" : undefined} data-scrolled="false">
<div class="relative mx-auto flex h-16 max-w-6xl items-center justify-between px-5">
<a href="/" class="flex items-center gap-2">
<span class={`font-serif text-xl font-semibold tracking-tight ${isHome ? "home-header-brand" : ""}`}>{SITE.name}</span>
</a>
<nav class="hidden items-center gap-8 md:flex" aria-label="Primary navigation">
{
nav.map((item) => {
const exact = item.to === "/";
const active = exact ? current === "/" : current.startsWith(item.to);
return (
<a
href={item.to}
class={`text-sm ${active ? activeClass : inactiveClass}`}
aria-current={active ? "page" : undefined}
>
{item.label}
</a>
);
})
}
</nav>
<div class="flex items-center gap-1">
<button
type="button"
data-search-toggle
aria-label="Search"
aria-controls="site-search"
aria-expanded="false"
class={iconClass}
>
<Icon name="search" class="h-4 w-4" />
</button>
<a
href="/rss.xml"
aria-label="RSS feed"
class={`${iconClass} hidden sm:inline-flex`}
>
<Icon name="rss" class="h-4 w-4" />
</a>
<button
type="button"
data-theme-toggle
aria-label="Toggle dark mode"
class={iconClass}
>
<span data-theme-icon="moon"><Icon name="moon" class="h-4 w-4" /></span>
<span data-theme-icon="sun" hidden><Icon name="sun" class="h-4 w-4" /></span>
</button>
<button
type="button"
data-menu-toggle
aria-label="Menu"
aria-controls="mobile-menu"
aria-expanded="false"
class={`${iconClass} md:hidden`}
>
<span data-menu-icon="menu"><Icon name="menu" class="h-4 w-4" /></span>
<span data-menu-icon="x" hidden><Icon name="x" class="h-4 w-4" /></span>
</button>
</div>
</div>
<div id="site-search" data-search-panel class="border-t border-border/60 bg-background text-foreground" hidden>
<form action="/blog" method="get" class="mx-auto max-w-6xl px-5 py-3">
<input
data-search-input
name="q"
aria-label="Search essays, field notes, interviews"
placeholder="Search essays, field notes, interviews..."
class="w-full border-0 bg-transparent py-2 font-serif text-lg outline-none placeholder:text-muted-foreground"
/>
</form>
</div>
<div id="mobile-menu" data-menu-panel class="border-t border-border/60 bg-background text-foreground md:hidden" hidden>
<nav class="mx-auto flex max-w-6xl flex-col gap-1 px-5 py-3" aria-label="Mobile navigation">
{
nav.map((item) => {
const exact = item.to === "/";
const active = exact ? current === "/" : current.startsWith(item.to);
return (
<a
href={item.to}
class={`rounded-md px-2 py-2 text-sm hover:bg-muted hover:text-foreground ${active ? "text-foreground" : "text-muted-foreground"}`}
aria-current={active ? "page" : undefined}
>
{item.label}
</a>
);
})
}
</nav>
</div>
</header>
<script>
const searchToggle = document.querySelector("[data-search-toggle]");
const searchPanel = document.querySelector("[data-search-panel]");
const searchInput = document.querySelector("[data-search-input]");
const menuToggle = document.querySelector("[data-menu-toggle]");
const menuPanel = document.querySelector("[data-menu-panel]");
const menuIcon = document.querySelector('[data-menu-icon="menu"]');
const closeIcon = document.querySelector('[data-menu-icon="x"]');
const themeToggle = document.querySelector("[data-theme-toggle]");
const moonIcon = document.querySelector('[data-theme-icon="moon"]');
const sunIcon = document.querySelector('[data-theme-icon="sun"]');
const homeHeader = document.querySelector("[data-home-header]");
const setPanelOpen = () => {
if (!homeHeader) return;
const open = !searchPanel.hidden || !menuPanel.hidden;
homeHeader.dataset.panelOpen = String(open);
};
const setMenu = (open) => {
menuPanel.hidden = !open;
menuIcon.hidden = open;
closeIcon.hidden = !open;
menuToggle?.setAttribute("aria-expanded", String(open));
setPanelOpen();
};
const setSearch = (open) => {
searchPanel.hidden = !open;
searchToggle?.setAttribute("aria-expanded", String(open));
if (open) searchInput?.focus();
setPanelOpen();
};
const setThemeIcon = () => {
const dark = document.documentElement.classList.contains("dark");
moonIcon.hidden = dark;
sunIcon.hidden = !dark;
};
setThemeIcon();
const setHeaderScrolled = () => {
if (!homeHeader) return;
homeHeader.dataset.scrolled = window.scrollY > 8 ? "true" : "false";
};
setHeaderScrolled();
window.addEventListener("scroll", setHeaderScrolled, { passive: true });
searchToggle?.addEventListener("click", () => {
setSearch(searchPanel.hidden);
});
menuToggle?.addEventListener("click", () => setMenu(menuPanel.hidden));
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
if (!searchPanel.hidden) {
setSearch(false);
searchToggle?.focus();
}
if (!menuPanel.hidden) {
setMenu(false);
menuToggle?.focus();
}
});
themeToggle?.addEventListener("click", () => {
const next = !document.documentElement.classList.contains("dark");
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
setThemeIcon();
});
</script>

View file

@ -0,0 +1,38 @@
---
const { name, class: className = "" } = Astro.props;
const paths = {
"arrow-left": '<path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path>',
"arrow-right": '<path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path>',
check: '<path d="M20 6 9 17l-5-5"></path>',
"chevron-right": '<path d="m9 18 6-6-6-6"></path>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>',
github: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5a10.4 10.4 0 0 0-5 0C9 2 8 2 8 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 7 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"></path><path d="M9 18c-4.5 2-5-2-7-2"></path>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>',
linkedin: '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-4 0v7h-4v-7a6 6 0 0 1 6-6z"></path><rect width="4" height="12" x="2" y="9"></rect><circle cx="4" cy="4" r="2"></circle>',
mail: '<rect width="20" height="16" x="2" y="4" rx="2"></rect><path d="m22 7-10 5L2 7"></path>',
menu: '<path d="M4 12h16"></path><path d="M4 6h16"></path><path d="M4 18h16"></path>',
message: '<path d="M21 15a4 4 0 0 1-4 4H7l-4 4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"></path>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>',
rss: '<path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle>',
search: '<path d="m21 21-4.34-4.34"></path><circle cx="11" cy="11" r="8"></circle>',
sun: '<circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="m4.93 4.93 1.41 1.41"></path><path d="m17.66 17.66 1.41 1.41"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="m6.34 17.66-1.41 1.41"></path><path d="m19.07 4.93-1.41 1.41"></path>',
twitter: '<path d="M22 4.01c-.77.35-1.6.58-2.47.69a4.3 4.3 0 0 0 1.89-2.38 8.6 8.6 0 0 1-2.73 1.04A4.28 4.28 0 0 0 11.4 7.27c0 .34.04.67.11.99A12.14 12.14 0 0 1 2.69 3.8a4.28 4.28 0 0 0 1.32 5.72 4.2 4.2 0 0 1-1.94-.54v.05a4.28 4.28 0 0 0 3.44 4.2 4.3 4.3 0 0 1-1.93.07 4.29 4.29 0 0 0 4 2.97A8.6 8.6 0 0 1 2.25 18.1c-.35 0-.7-.02-1.04-.06A12.13 12.13 0 0 0 7.77 20c7.87 0 12.18-6.52 12.18-12.18v-.56A8.7 8.7 0 0 0 22 4.01Z"></path>',
x: '<path d="M18 6 6 18"></path><path d="m6 6 12 12"></path>',
};
---
<svg
class={className}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
set:html={paths[name] ?? ""}
/>

View file

@ -0,0 +1,61 @@
---
const { compact = false } = Astro.props;
---
{
compact ? (
<div>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Newsletter</div>
<p class="mt-2 text-sm text-muted-foreground">Medellín dining in your inbox — new guides and openings.</p>
<form data-newsletter-form class="mt-3 flex gap-2">
<input
type="email"
required
aria-label="Email address"
placeholder="you@email.com"
class="min-w-0 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-primary"
/>
<button type="submit" class="rounded-md bg-foreground px-3 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90">
Join
</button>
</form>
<p data-newsletter-done class="mt-2 text-xs text-primary" role="status" hidden>Thanks &mdash; check your inbox.</p>
</div>
) : (
<section data-newsletter-cta class="border-t border-border/60 pt-16 pb-10">
<div class="mx-auto max-w-2xl px-5 text-center">
<h2 class="font-serif text-3xl font-semibold tracking-tight sm:text-4xl">Eat well in Medellín.</h2>
<p class="mt-3 text-muted-foreground">
One email when it&rsquo;s worth it &mdash; new restaurant guides, openings, and data-driven dining picks for Medellín.
</p>
<form data-newsletter-form class="mx-auto mt-6 flex max-w-md flex-col gap-2 sm:flex-row">
<input
type="email"
required
aria-label="Email address"
placeholder="you@email.com"
class="min-w-0 flex-1 rounded-md border border-input bg-background px-4 py-3 text-sm outline-none focus:border-primary"
/>
<button type="submit" class="rounded-md bg-foreground px-5 py-3 text-sm font-medium text-background transition-opacity hover:opacity-90">
Subscribe
</button>
</form>
<p data-newsletter-done class="mt-3 text-sm text-primary" role="status" hidden>Thanks &mdash; check your inbox to confirm.</p>
<p class="mt-3 text-xs text-muted-foreground">Free. Unsubscribe in one click.</p>
</div>
</section>
)
}
<script>
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.matches("[data-newsletter-form]")) return;
event.preventDefault();
const input = form.querySelector("input");
if (!input?.value) return;
input.value = "";
const done = form.parentElement?.querySelector("[data-newsletter-done]");
if (done) done.hidden = false;
});
</script>

View file

@ -0,0 +1,148 @@
---
import { Image } from "astro:assets";
import { getAuthor, getCategory, formatDate } from "../lib/blog-data.js";
const { post, variant = "default", hidden = false } = Astro.props;
const author = getAuthor(post.author);
const category = getCategory(post.category);
const searchable = `${post.title} ${post.excerpt}`.toLowerCase();
const thumbnailIsString = typeof post.thumbnail === "string";
---
{
variant === "compact" ? (
<a
href={`/blog/${post.slug}`}
class="group block"
data-post-card
data-category={post.category}
data-tags={post.tags.join(" ")}
data-search={searchable}
hidden={hidden}
>
<div class="text-xs uppercase tracking-wider text-muted-foreground">{category?.name}</div>
<h3 class="mt-1 font-serif text-lg font-semibold leading-snug tracking-tight text-foreground group-hover:text-primary">
{post.title}
</h3>
<div class="mt-1 text-xs text-muted-foreground">
{formatDate(post.date)} &middot; {post.readingTime} min
</div>
</a>
) : variant === "list" ? (
<article
class="group grid gap-6 border-b border-border/60 py-8 sm:grid-cols-[1fr_220px]"
data-post-card
data-category={post.category}
data-tags={post.tags.join(" ")}
data-search={searchable}
hidden={hidden}
>
<div>
<div class="flex items-center gap-3 text-xs uppercase tracking-wider text-muted-foreground">
{category && (
<a href={`/categories/${category.slug}`} class="hover:text-foreground">
{category.name}
</a>
)}
<span>&middot;</span>
<time datetime={post.date}>{formatDate(post.date)}</time>
</div>
<a href={`/blog/${post.slug}`}>
<h2 class="mt-2 font-serif text-2xl font-semibold leading-tight tracking-tight group-hover:text-primary sm:text-3xl">
{post.title}
</h2>
</a>
<p class="mt-3 text-muted-foreground">{post.excerpt}</p>
<div class="mt-4 flex items-center gap-3 text-xs text-muted-foreground">
{author && (
<a href={`/authors/${author.slug}`} class="hover:text-foreground">
{author.name}
</a>
)}
<span>&middot;</span>
<span>{post.readingTime} min read</span>
</div>
</div>
{post.thumbnail && (
<a href={`/blog/${post.slug}`} class="block" aria-label={`Read ${post.title}`}>
<div class="aspect-[4/3] overflow-hidden rounded-md bg-muted sm:aspect-square">
{
thumbnailIsString ? (
<img
src={post.thumbnail}
alt=""
width="440"
height="330"
loading="lazy"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>
) : (
<Image
src={post.thumbnail}
alt=""
loading="lazy"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>
)
}
</div>
</a>
)}
</article>
) : (
<article
class="group"
data-post-card
data-category={post.category}
data-tags={post.tags.join(" ")}
data-search={searchable}
hidden={hidden}
>
{post.thumbnail && (
<a href={`/blog/${post.slug}`} class="block" aria-label={`Read ${post.title}`}>
<div class="aspect-[16/10] overflow-hidden rounded-md bg-muted">
{
thumbnailIsString ? (
<img
src={post.thumbnail}
alt=""
width="640"
height="400"
loading="lazy"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>
) : (
<Image
src={post.thumbnail}
alt=""
loading="lazy"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>
)
}
</div>
</a>
)}
<div class="mt-4">
<div class="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground">
{category && (
<a href={`/categories/${category.slug}`} class="hover:text-foreground">
{category.name}
</a>
)}
<span>&middot;</span>
<time datetime={post.date}>{formatDate(post.date)}</time>
</div>
<a href={`/blog/${post.slug}`}>
<h3 class="mt-2 font-serif text-xl font-semibold leading-snug tracking-tight group-hover:text-primary">
{post.title}
</h3>
</a>
<p class="mt-2 line-clamp-2 text-sm text-muted-foreground">{post.excerpt}</p>
<div class="mt-3 text-xs text-muted-foreground">
{author?.name} &middot; {post.readingTime} min
</div>
</div>
</article>
)
}

View file

@ -0,0 +1,74 @@
---
import Icon from "./Icon.astro";
import PostCard from "./PostCard.astro";
import Newsletter from "./Newsletter.astro";
import { categories, tags, popularPosts, sortedPosts, authors } from "../lib/blog-data.js";
const recent = (await sortedPosts()).slice(0, 4);
const popular = await popularPosts();
const author = authors[0];
---
<aside class="space-y-10">
<div>
<div class="flex items-center gap-3">
<img src={author.avatar} alt="" width="40" height="40" class="h-10 w-10 rounded-full" />
<div>
<div class="text-sm font-medium">{author.name}</div>
<div class="text-xs text-muted-foreground">Editor</div>
</div>
</div>
<p class="mt-3 text-sm text-muted-foreground">{author.bio}</p>
</div>
<div>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Categories</div>
<ul class="mt-3 space-y-2 text-sm">
{
categories.map((category) => (
<li>
<a href={`/categories/${category.slug}`} class="text-foreground/80 hover:text-primary">
{category.name}
</a>
</li>
))
}
</ul>
</div>
<div>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Popular</div>
<div class="mt-3 space-y-4">
{popular.map((post) => <PostCard post={post} variant="compact" />)}
</div>
</div>
<div>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Recent</div>
<div class="mt-3 space-y-4">
{recent.map((post) => <PostCard post={post} variant="compact" />)}
</div>
</div>
<div>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Tags</div>
<div class="mt-3 flex flex-wrap gap-2">
{
tags.map((tag) => (
<a
href={`/tags/${tag.slug}`}
class="rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground hover:border-primary hover:text-primary"
>
{tag.name}
</a>
))
}
</div>
</div>
<Newsletter compact />
<a href="/rss.xml" class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-primary">
<Icon name="rss" class="h-3.5 w-3.5" /> Subscribe via RSS
</a>
</aside>

View file

@ -0,0 +1,63 @@
---
const { items = [] } = Astro.props;
---
{
items.length > 0 && (
<div data-toc>
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">On this page</div>
<ul class="mt-3 space-y-2 border-l border-border">
{items.map((item) => (
<li data-toc-item class="relative" style={`padding-left: ${item.level === 3 ? 24 : 12}px`}>
<a
href={`#${item.id}`}
data-toc-link={item.id}
class="block py-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{item.text}
</a>
</li>
))}
</ul>
</div>
)
}
<style>
[data-toc-item].is-active::before {
background: var(--color-primary);
bottom: 0.125rem;
content: "";
left: -1px;
position: absolute;
top: 0.125rem;
width: 1px;
}
</style>
<script>
const toc = document.querySelector("[data-toc]");
if (toc) {
const links = [...toc.querySelectorAll("[data-toc-link]")];
const setActive = (id) => {
links.forEach((link) => {
const active = link.getAttribute("data-toc-link") === id;
link.classList.toggle("text-primary", active);
link.classList.toggle("text-muted-foreground", !active);
link.closest("[data-toc-item]")?.classList.toggle("is-active", active);
});
};
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((entry) => entry.isIntersecting);
if (visible[0]) setActive(visible[0].target.id);
},
{ rootMargin: "-80px 0px -70% 0px" },
);
links.forEach((link) => {
const id = link.getAttribute("data-toc-link");
const heading = id ? document.getElementById(id) : null;
if (heading) observer.observe(heading);
});
}
</script>

35
app/src/content.config.js Normal file
View file

@ -0,0 +1,35 @@
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({
pattern: "**/index.mdx",
base: "./src/content/blog",
generateId: ({ entry }) => entry.replace(/[\\/]index\.mdx$/, "").replace(/\\/g, "/"),
}),
schema: ({ image }) =>
z.object({
title: z.string(),
excerpt: z.string(),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
readingTime: z.number().int().positive(),
category: z.string(),
tags: z.array(z.string()).default([]),
author: z.string(),
thumbnail: image(),
imageCredit: z
.object({
caption: z.string().optional(),
author: z.string(),
authorUrl: z.string().url(),
source: z.string().optional(),
sourceUrl: z.string().url().optional(),
})
.optional(),
featured: z.boolean().default(false),
}),
});
export const collections = { blog };

View file

@ -0,0 +1,95 @@
---
import "../styles.css";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import DevConsole from "../components/DevConsole.astro";
import { SITE, imageSrc } from "../lib/blog-data.js";
const {
title = `${SITE.name} - Medellín's restaurant scene for expats, nomads & travelers`,
description = SITE.description,
canonical,
ogType = "website",
ogImage,
jsonLd,
flushFooter = false,
} = Astro.props;
const siteUrl = Astro.site?.toString() || SITE.url;
const absoluteUrl = (value) => {
const src = imageSrc(value) || value;
if (!src) return undefined;
try {
return new URL(src, siteUrl).toString();
} catch {
return src;
}
};
const canonicalUrl = absoluteUrl(canonical || Astro.url.pathname);
const ogImageUrl = absoluteUrl(ogImage);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="author" content={SITE.name} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={SITE.name} />
{canonicalUrl && <meta property="og:url" content={canonicalUrl} />}
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{ogImageUrl && <meta name="twitter:image" content={ogImageUrl} />}
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
<link
rel="preload"
href="/fonts/inter-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/fraunces-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link rel="alternate" type="application/rss+xml" title={SITE.name} href="/rss.xml" />
{
jsonLd && (
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
)
}
<script is:inline>
(function () {
try {
var theme = localStorage.getItem("theme");
var dark = theme
? theme === "dark"
: matchMedia("(prefers-color-scheme: dark)").matches;
if (dark) document.documentElement.classList.add("dark");
} catch (error) {}
})();
</script>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to content</a>
<div class="flex min-h-dvh flex-col">
<Header />
<main id="main-content" class="flex-1">
<slot />
</main>
<Footer flush={flushFooter} />
</div>
<DevConsole />
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more