# Portfolio + Admin Panel 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:** Bangun website portofolio publik (single-page, Inggris) + admin panel (Indonesia) dari dua prototipe HTML (`portofolio.html`, `admin.html`) dengan PHP 8.2, Slim 4, MariaDB, Tailwind CDN + Alpine.js CDN.

**Architecture:** Monolith Slim 4 di shared hosting (docroot tetap `public_html`, semua file di root, `.htaccess` rewrite). Template PHP biasa (bukan Twig), PDO prepared statements + repository classes tanpa ORM. Admin multi-route `/admin/*` dengan session auth + CSRF; publik 1 halaman dengan visitor tracking cookie.

**Tech Stack:** PHP 8.2, slim/slim ^4, slim/psr7 ^1, MariaDB (PDO mysql), Tailwind CDN, Alpine.js CDN (unpkg), Lucide CDN (unpkg), PHPUnit ^11 (dev), Composer.

**Spec:** `docs/superpowers/specs/2026-10-10-portfolio-admin-design.md` — semua keputusan ada di sana; plan ini menurutinya.

## Global Constraints

- PHP >= 8.2, `declare(strict_types=1)` di semua file PHP baru.
- Tanpa ORM, tanpa Twig, tanpa monolog, tanpa template engine — template PHP biasa.
- Tailwind/Alpine/Lucide tetap dari CDN (konfigurasi inline seperti prototipe).
- Database: remote production (`163.223.227.38`, db `gzcxepzd_portofolio`) — **hanya migrasi additive** (`CREATE TABLE IF NOT EXISTS`); dilarang DROP/TRUNCATE/DELETE massal.
- Semua output HTML melalui `e()`. Semua query SQL via prepared statements.
- Setiap POST form membawa `_token` CSRF; middleware menolak jika invalid.
- Commit git di akhir setiap task dengan pesan `feat:`/`fix:`/`chore:` + deskripsi.
- UI publik Bahasa Inggris; UI admin Bahasa Indonesia (ikuti prototipe).
- Dibuang: form kontak publik, AI auto-reply, multi-user. Pesan admin diisi manual.
- `.env` TIDAK boleh di-commit (`.gitignore`). `db.txt` dihapus dari repo di Task 13.
- Working directory: `C:\Users\rafa\Portofolio` (Windows, PowerShell 5.1). Jalankan `php` via `& "php"` atau `php` jika sudah di PATH; setelah winget, tambahkan `$env:Path += ";$env:LOCALAPPDATA\Microsoft\WinGet\Links"` bila perlu.

## Core Contracts (berlaku di semua task)

**Namespace:** `App\` PSR-4 → `src/` (composer autoload). Fungsi global `e()` di-autoload via `composer.files` → `src/functions.php`.

**Env keys:** `APP_ENV` (`development`|`production`), `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASS`, `ADMIN_INITIAL_PASSWORD` (hanya dipakai Task 2 generate hash; tidak dipakai runtime).

**Session keys:** `admin_id` (int, ada = login), `_csrf` (string 64 hex), `_flash` (array of ['type'=>'success'|'error', 'message'=>string]).

**Cookie:** `pv_id` (char 32 hex, 1 tahun, SameSite=Lax, HttpOnly) — visitor id analytics.

**HTTP contracts:**
- Flash: controller `$_SESSION['_flash'][] = ['type'=>..., 'message'=>...]` lalu redirect; layout memanggil `Flash::pull()` sekali lalu unset.
- CSRF field: `<input type="hidden" name="_token" value="<?= e($_SESSION['_csrf']) ?>">`.
- Redirect-after-post selalu 302 ke GET.
- Maintenance: `live_site != '1'` dan bukan admin login → HTTP 503 + `templates/public/maintenance.php`.

**Class map (nama & signature yang dipakai lintas task):**

```php
namespace App\Support;
final class Env {
    public static function load(string $path): void;           // parse KEY=VALUE
    public static function get(string $key, ?string $default = null): ?string;
}
final class Container {
    public static function pdo(): \PDO;                        // singleton, ERRMODE_EXCEPTION, FETCH_ASSOC
    public static function baseDir(): string;                  // dirname(__DIR__) = project root
}
final class View {
    // render templates/$template.php dengan $data extracted, hasilnya
    // dijadikan $content lalu render templates/$layout.php dengan $data + $content
    public static function render(string $template, array $data = [], string $layout = 'public/layout'): string;
}
final class Flash {
    public static function add(string $type, string $message): void;
    public static function pull(): array;                      // ambil + hapus dari session
}
final class Validator {
    // rules: 'required' | 'email' | 'min:N' | 'max:N' | 'year' | 'int'
    // return array error messages, kosong = valid
    public static function validate(array $input, array $rules): array;
}
final class Uploader {
    // validasi ekstensi+mime+size, nama acak, return filename baru;
    // throws \RuntimeException dengan pesan user-friendly
    public static function store(\Slim\Psr7\UploadedFile $file, string $subdir = ''): string;
    public static function delete(?string $filename, string $subdir = ''): void;
    public const MAX_BYTES = 2 * 1024 * 1024;
    public const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
}
function e(?string $value): string;                             // htmlspecialchars ENT_QUOTES UTF-8

namespace App\Repositories;
final class AdminRepository   { public function __construct(\PDO $pdo) {}
    public function findByUsername(string $username): ?array;    // [id, username, password_hash, display_name]
    public function find(int $id): ?array;
    public function updatePassword(int $id, string $hash): void; }
final class LoginAttemptRepository { public function __construct(\PDO $pdo) {}
    public function recentCount(string $ip, int $minutes = 15): int;
    public function record(string $ip): void;
    public function clear(string $ip): void; }
final class SettingRepository { public function __construct(\PDO $pdo) {}
    public function all(): array;                                // key=>value, di-cache per-request
    public function get(string $key, ?string $default = null): ?string;
    public function setMany(array $pairs): void;                 // INSERT ... ON DUPLICATE KEY UPDATE
    public function clearCache(): void; }
final class ProjectRepository { public function __construct(\PDO $pdo) {}
    public function paginate(int $page, int $perPage = 10): array; // ['items','total','pages','page']
    public function allLive(): array;                            // status=live, ORDER BY sort_order, id
    public function find(int $id): ?array;                       // tech_stack/highlights sudah di-decode array
    public function create(array $data): int;
    public function update(int $id, array $data): void;
    public function setStatus(int $id, string $status): void;
    public function delete(int $id): void;
    public function countAll(): int;
    public function latest(int $limit): array; }
final class ServiceRepository { public function __construct(\PDO $pdo) {}
    public function paginate(int $page, int $perPage = 10): array;
    public function allActive(): array;                          // ORDER BY sort_order
    public function find(int $id): ?array;
    public function create(array $data): int;
    public function update(int $id, array $data): void;
    public function delete(int $id): void; }
final class MessageRepository { public function __construct(\PDO $pdo) {}
    public function paginate(int $page, int $perPage = 10): array; // unread dulu, created_at DESC
    public function latest(int $limit): array;
    public function create(array $data): int;
    public function toggleRead(int $id): void;
    public function delete(int $id): void;
    public function unreadCount(): int;
    public function countAll(): int; }
final class PageViewRepository { public function __construct(\PDO $pdo) {}
    public function log(string $visitorId, string $path, ?string $referer): void;
    public function dailyTotals(int $days): array;               // zero-filled: [['date'=>'Y-m-d','label'=>'Sen','views'=>n,'unique'=>n],...]
    public function countRange(int $days): int;                  // total views N hari terakhir
    public function uniqueRange(int $days): int;                 // COUNT(DISTINCT visitor_id)
    public function topPaths(int $days, int $limit = 10): array; // [['path','views','unique'],...] }
final class ActivityRepository { public function __construct(\PDO $pdo) {}
    public function add(string $title, string $description, ?string $url = null): void;
    public function latest(int $limit = 8): array; }

namespace App\Middleware;
final class CsrfCheck    { public function __invoke(\Psr\Http\Message\ServerRequestInterface $r, \Psr\Http\Message\RequestHandlerInterface $h): \Psr\Http\Message\ResponseInterface; }
final class AuthRequired { public function __invoke(...): ...; }  // session admin_id kosong → 302 /admin/login
final class VisitorTracker { public function __invoke(...): ...; }// hanya dipasang di route GET /
```

**Template paths:** `templates/public/{layout,nav,hero,marquee,work,about,services,contact,footer,maintenance,home}.php`, `templates/admin/{layout,login,dashboard,projects,project-form,services,service-form,messages,analytics,settings,_traffic_chart}.php`.

**Konvensi form:** method POST, field name = snake_case sesuai kolom DB, error per-field di bawah input (`$errors['title'] ?? ''`), old input via `$old = $_POST` saat render ulang.

---

### Task 1: Toolchain + scaffold + git

**Files:**
- Create: `composer.json`, `.gitignore`, `index.php`, `phpunit.xml`, `tests/bootstrap.php`, `tests/SmokeTest.php`

**Interfaces:**
- Produces: script `php -S localhost:8000 index.php` melayani route `GET /` → 200 "ok"; `composer test` jalan.

- [ ] **Step 1: Install toolchain via winget**

```powershell
winget install --id PHP.PHP.8.2 --exact --accept-package-agreements --accept-source-agreements
winget install --id Composer.Composer --exact --accept-package-agreements --accept-source-agreements
$env:Path += ";$env:LOCALAPPDATA\Microsoft\WinGet\Links"
php -v    # harus PHP 8.2.x
composer -V
```
Expected: php 8.2.x dan composer terpasang. Jika PATH tidak update di sesi yang sama, gunakan path penuh `%LOCALAPPDATA%\Microsoft\WinGet\Links\php.exe`.

- [ ] **Step 2: git init + .gitignore**

```powershell
git init
git config user.name "rafa"   # skip jika sudah global
git config user.email "rafa@users.noreply.github.com"
```

`.gitignore`:
```
/vendor/
.env
/storage/logs/*.log
/uploads/*
!/uploads/.htaccess
composer.lock.bak
```

- [ ] **Step 3: composer.json + install dependency**

```json
{
  "name": "masum/portfolio",
  "description": "Portfolio site + admin panel (Slim 4)",
  "type": "project",
  "require": {
    "php": ">=8.2",
    "slim/slim": "^4.14",
    "slim/psr7": "^1.7"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.5"
  },
  "autoload": {
    "psr-4": { "App\\": "src/" },
    "files": [ "src/functions.php" ]
  },
  "autoload-dev": {
    "psr-4": { "Tests\\": "tests/" }
  },
  "scripts": {
    "test": "phpunit"
  }
}
```

`src/functions.php`:
```php
<?php
declare(strict_types=1);

if (!function_exists('e')) {
    function e(?string $value): string
    {
        return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
    }
}
```

Jalankan `composer dump-autoload` lalu `composer require slim/slim slim/psr7` dan `composer require --dev phpunit/phpunit` (atau cukup `composer install` setelah file ada — pastikan `vendor/` terisi).

- [ ] **Step 4: index.php minimal + phpunit**

`index.php`:
```php
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

$app = \Slim\Factory\AppFactory::create();
$app->get('/', function (Request $request, Response $response) {
    $response->getBody()->write('ok');
    return $response;
});
$app->run();
```

`phpunit.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php" colors="true">
  <testsuites>
    <testsuite name="unit"><directory>tests</directory></testsuite>
  </testsuites>
</phpunit>
```

`tests/bootstrap.php`: `<?php declare(strict_types=1); require dirname(__DIR__) . '/vendor/autoload.php';`

`tests/SmokeTest.php`:
```php
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class SmokeTest extends TestCase
{
    public function testEscaping(): void
    {
        $this->assertSame('&lt;b&gt;x&lt;/b&gt;', e('<b>x</b>'));
        $this->assertSame('', e(null));
    }
}
```

- [ ] **Step 5: Verifikasi**

```powershell
composer test
# terminal 1:
php -S localhost:8000 index.php
# terminal 2:
curl.exe -s http://localhost:8000/    # Expected: ok
```

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "chore: scaffold Slim 4 project with toolchain and smoke test"
```

---

### Task 2: .env loader + PDO container + error handling

**Files:**
- Create: `src/Support/Env.php`, `src/Support/Container.php`, `src/bootstrap.php`, `.env`, `.env.example`
- Modify: `index.php` (delegate ke bootstrap)
- Test: `tests/EnvTest.php`

**Interfaces:**
- Consumes: Task 1 scaffold.
- Produces: `Env::load/get`, `Container::pdo/baseDir`, `bootstrap.php` mengembalikan `\Slim\App` yang sudah terkonfigurasi (error handler, 404, session_start).

- [ ] **Step 1: Tulis failing test Env**

`tests/EnvTest.php`:
```php
<?php
declare(strict_types=1);

use App\Support\Env;
use PHPUnit\Framework\TestCase;

final class EnvTest extends TestCase
{
    private string $file;

    protected function setUp(): void
    {
        $this->file = tempnam(sys_get_temp_dir(), 'env');
        file_put_contents($this->file, "APP_ENV=development\nDB_HOST=\"127.0.0.1\"\n# comment\nEMPTY_VAL=\n");
    }

    protected function tearDown(): void
    {
        @unlink($this->file);
        // reset state statis antar test
        $ref = new ReflectionClass(Env::class);
        $prop = $ref->getProperty('values');
        $prop->setAccessible(true);
        $prop->setValue([]);
    }

    public function testParsesValues(): void
    {
        Env::load($this->file);
        $this->assertSame('development', Env::get('APP_ENV'));
        $this->assertSame('127.0.0.1', Env::get('DB_HOST'));
        $this->assertSame('fallback', Env::get('NOPE', 'fallback'));
        $this->assertNull(Env::get('NOPE2'));
        $this->assertSame('', Env::get('EMPTY_VAL'));
    }
}
```

- [ ] **Step 2: Jalankan, pastikan FAIL**

Run: `composer test` → Expected: FAIL (class App\Support\Env not found).

- [ ] **Step 3: Implement Env + Container**

`src/Support/Env.php`:
```php
<?php
declare(strict_types=1);

namespace App\Support;

final class Env
{
    /** @var array<string,string|null> */
    private static array $values = [];

    public static function load(string $path): void
    {
        if (!is_readable($path)) {
            return;
        }
        foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
            $line = trim($line);
            if ($line === '' || str_starts_with($line, '#')) {
                continue;
            }
            [$key, $value] = array_pad(explode('=', $line, 2), 2, null);
            $key = trim($key);
            $value = trim((string) $value);
            if (strlen($value) >= 2 && ($value[0] === '"' || $value[0] === "'") && $value[0] === $value[strlen($value) - 1]) {
                $value = substr($value, 1, -1);
            }
            self::$values[$key] = $value;
        }
    }

    public static function get(string $key, ?string $default = null): ?string
    {
        return self::$values[$key] ?? $default;
    }
}
```

`src/Support/Container.php`:
```php
<?php
declare(strict_types=1);

namespace App\Support;

use PDO;

final class Container
{
    private static ?PDO $pdo = null;

    public static function pdo(): PDO
    {
        if (self::$pdo === null) {
            $dsn = sprintf(
                'mysql:host=%s;dbname=%s;charset=utf8mb4',
                Env::get('DB_HOST', 'localhost'),
                Env::get('DB_NAME', 'portofolio')
            );
            self::$pdo = new PDO($dsn, Env::get('DB_USER', 'root'), Env::get('DB_PASS', ''), [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false,
            ]);
        }
        return self::$pdo;
    }

    public static function baseDir(): string
    {
        return dirname(__DIR__, 2);
    }
}
```

- [ ] **Step 4: bootstrap.php + index.php + .env**

`src/bootstrap.php`:
```php
<?php
declare(strict_types=1);

use App\Support\Env;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
use Slim\Routing\RouteCollectorProxy;

require_once __DIR__ . '/../vendor/autoload.php';

Env::load(dirname(__DIR__) . '/.env');

if (session_status() === PHP_SESSION_NONE) {
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
}

AppFactory::setContainer(new \Slim\Container([])); // hanya jika slim/container tersedia — HAPUS baris ini jika error; cukup:
$app = AppFactory::create();

$isDev = Env::get('APP_ENV', 'production') === 'development';

$errorHandler = $app->getErrorHandler();
$errorHandler->registerErrorHandler(function (Throwable $e, Request $request) use ($isDev): Response {
    error_log(sprintf('[%s] %s in %s:%d', date('Y-m-d H:i:s'), $e->getMessage(), $e->getFile(), $e->getLine()));
    $response = new \Slim\Psr7\Response(500);
    if ($isDev) {
        $response->getBody()->write('<pre>' . htmlspecialchars((string) $e, ENT_QUOTES, 'UTF-8') . '</pre>');
    } else {
        $body = is_readable(__DIR__ . '/../templates/public/error500.php')
            ? (static function () use ($e) {
                ob_start();
                include __DIR__ . '/../templates/public/error500.php';
                return (string) ob_get_clean();
            })()
            : 'Internal Server Error';
        $response->getBody()->write($body);
    }
    return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
});
$errorHandler->registerNotFoundHandler(function (Request $request): Response {
    $response = new \Slim\Psr7\Response(404);
    $template = str_starts_with($request->getUri()->getPath(), '/admin')
        ? __DIR__ . '/../templates/admin/error404.php'
        : __DIR__ . '/../templates/public/error404.php';
    if (is_readable($template)) {
        ob_start();
        include $template;
        $response->getBody()->write((string) ob_get_clean());
    } else {
        $response->getBody()->write('Not Found');
    }
    return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
});

// CSRF token siap di session
if (empty($_SESSION['_csrf'])) {
    $_SESSION['_csrf'] = bin2hex(random_bytes(32));
}

// ROUTES DISINI (Task 5+, sementara smoke route:)
$app->get('/healthz', function (Request $request, Response $response) {
    try {
        Container::pdo()->query('SELECT 1');
        $response->getBody()->write('db-ok');
    } catch (Throwable $e) {
        $response->getBody()->write('db-fail: ' . ($isDev ? $e->getMessage() : 'config'));
    }
    return $response;
});

return $app;
```

**Penting:** hapus baris `AppFactory::setContainer(...)` di atas jika `slim/container` tidak terpasang (Slim 4 core tidak lagi membutuhkannya). Cukup `AppFactory::create()`.

`index.php`:
```php
<?php
declare(strict_types=1);

$app = require __DIR__ . '/src/bootstrap.php';
$app->run();
```

`.env.example`:
```
APP_ENV=development
DB_HOST=163.223.227.38
DB_NAME=gzcxepzd_portofolio
DB_USER=gzcxepzd_portofolio
DB_PASS=ISI_DENGAN_PASSWORD_DB
```

`.env` (nilai dari `db.txt`: user `gzcxepzd_portofolio`, pass `o%{EgE_#]}w89VbN`, host `163.223.227.38`, db `gzcxepzd_portofolio`).

- [ ] **Step 5: Jalankan test + smoke DB**

```powershell
composer test                          # EnvTest PASS
php -S localhost:8000 index.php        # terminal 1
curl.exe -s http://localhost:8000/healthz   # Expected: db-ok
```

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: env loader, PDO container, error handling bootstrap"
```

---

### Task 3: Migrasi + seed admin + bin/migrate.php

**Files:**
- Create: `migrations/001_init.sql`, `migrations/full.sql`, `bin/migrate.php`

**Interfaces:**
- Consumes: `Container::pdo()`, `.env`.
- Produces: tabel `schema_migrations` + 8 tabel dari spec; 1 baris `admins` (username `admin`, password = `ChangeMe_2026!`).

- [ ] **Step 1: Generate hash bcrypt**

```powershell
php -r "echo password_hash('ChangeMe_2026!', PASSWORD_DEFAULT), PHP_EOL;"
```
Simpan outputnya — dipakai di INSERT seed (ganti `HASH_DISINI`).

- [ ] **Step 2: Tulis `migrations/001_init.sql`**

```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  filename VARCHAR(190) NOT NULL UNIQUE,
  applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS admins (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(64) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  display_name VARCHAR(120) NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS login_attempts (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  ip VARCHAR(45) NOT NULL,
  attempted_at DATETIME NOT NULL,
  INDEX idx_ip_time (ip, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS projects (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(160) NOT NULL,
  subtitle VARCHAR(200) NOT NULL DEFAULT '',
  year SMALLINT UNSIGNED NOT NULL,
  tech_stack JSON NOT NULL,
  status ENUM('live','draft') NOT NULL DEFAULT 'draft',
  description TEXT NOT NULL,
  highlights JSON NOT NULL,
  cover_image VARCHAR(190) NULL,
  icon VARCHAR(64) NULL,
  sort_order SMALLINT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_status_sort (status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS services (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(160) NOT NULL,
  category ENUM('development','automation','data') NOT NULL,
  icon VARCHAR(64) NOT NULL,
  description TEXT NOT NULL,
  features JSON NOT NULL,
  sort_order SMALLINT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS messages (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  sender_name VARCHAR(120) NOT NULL,
  email VARCHAR(190) NOT NULL,
  body TEXT NOT NULL,
  is_read TINYINT(1) NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_read_created (is_read, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS settings (
  `key` VARCHAR(64) PRIMARY KEY,
  value TEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS page_views (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  visitor_id CHAR(32) NOT NULL,
  path VARCHAR(190) NOT NULL,
  referer VARCHAR(190) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_created (created_at),
  INDEX idx_visitor (visitor_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS activities (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(190) NOT NULL,
  description VARCHAR(255) NOT NULL,
  url VARCHAR(190) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

INSERT IGNORE INTO admins (username, password_hash, display_name) VALUES
  ('admin', 'HASH_DISINI', 'Masum');
```

- [ ] **Step 3: Tulis `bin/migrate.php`**

```php
<?php
declare(strict_types=1);

// Pemakaian: php bin/migrate.php
// Menjalankan migrations/*.sql yang belum tercatat di schema_migrations (idempoten).

require __DIR__ . '/../vendor/autoload.php';

use App\Support\Container;
use App\Support\Env;

Env::load(dirname(__DIR__) . '/.env');
$pdo = Container::pdo();

$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  filename VARCHAR(190) NOT NULL UNIQUE,
  applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");

$applied = $pdo->query('SELECT filename FROM schema_migrations')->fetchAll(\PDO::FETCH_COLUMN);
$files = glob(dirname(__DIR__) . '/migrations/*.sql');
sort($files);

foreach ($files as $file) {
    $name = basename($file);
    if (in_array($name, $applied, true)) {
        echo "skip  $name\n";
        continue;
    }
    echo "apply $name\n";
    $sql = file_get_contents($file);
    $pdo->exec($sql); // file 001 dibuat aman multi-statement via exec PDO mysql
    $stmt = $pdo->prepare('INSERT INTO schema_migrations (filename) VALUES (?)');
    $stmt->execute([$name]);
}
echo "done\n";
```

Catatan: `full.sql` = copy isi `001_init.sql` (tanpa baris schema_migrations ganda — buat full.sql berisi semua CREATE + INSERT seed; file ini untuk import phpManAdmin manual, TIDAK dijalankan migrate.php). Karena nama unik di `schema_migrations`, cukup daftarkan `001_init.sql` saja yang dijalankan otomatis; `full.sql` jangan ditaruh di folder `migrations/` — taruh di root project `migrations-export.sql` ATAU daftarkan tapi migrate.php harus skip `full*`. Keputusan: **`full.sql` taruh di root project** (`full.sql`), bukan di `migrations/`.

- [ ] **Step 4: Jalankan migrasi + verifikasi**

```powershell
php bin/migrate.php
php -r "require 'vendor/autoload.php'; App\Support\Env::load('.env'); $p=App\Container::pdo(); var_dump($p->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN)); var_dump($p->query('SELECT username FROM admins')->fetchAll());"
```
Expected: 9 tabel (termasuk schema_migrations) + 1 admin `admin`.

- [ ] **Step 5: Buat `full.sql`** — gabungkan semua isi `001_init.sql` jadi satu file `full.sql` di root (untuk import phpMyAdmin di hosting).

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: database migrations, admin seed, migrate runner"
```

---

### Task 4: Support classes (View, Flash, Validator, Uploader) + tests

**Files:**
- Create: `src/Support/View.php`, `src/Support/Flash.php`, `src/Support/Validator.php`, `src/Support/Uploader.php`, `templates/public/layout.php`, `templates/admin/layout.php` (stub minimal dulu)
- Test: `tests/ValidatorTest.php`, `tests/FlashTest.php`, `tests/ViewTest.php`

**Interfaces:**
- Consumes: Core Contracts.
- Produces: kelas persis signature di Core Contracts; layout templates punya `$content` + `$data['pageTitle']`.

- [ ] **Step 1: Failing tests**

`tests/ValidatorTest.php`:
```php
<?php
declare(strict_types=1);

use App\Support\Validator;
use PHPUnit\Framework\TestCase;

final class ValidatorTest extends TestCase
{
    public function testRequiredAndMax(): void
    {
        $errors = Validator::validate(['title' => ''], ['title' => 'required|min:3|max:160']);
        $this->assertArrayHasKey('title', $errors);

        $errors = Validator::validate(['title' => str_repeat('a', 200)], ['title' => 'required|max:160']);
        $this->assertArrayHasKey('title', $errors);

        $errors = Validator::validate(['title' => 'Bible Aura'], ['title' => 'required|min:3|max:160']);
        $this->assertSame([], $errors);
    }

    public function testEmailAndYearAndInt(): void
    {
        $this->assertArrayHasKey('email', Validator::validate(['email' => 'bukan-email'], ['email' => 'required|email']));
        $this->assertSame([], Validator::validate(['email' => 'a@b.co'], ['email' => 'required|email']));
        $this->assertArrayHasKey('year', Validator::validate(['year' => '1800'], ['year' => 'required|year']));
        $this->assertArrayHasKey('page', Validator::validate(['page' => 'x'], ['page' => 'int']));
        $this->assertSame([], Validator::validate(['page' => '3'], ['page' => 'int']));
    }
}
```

`tests/FlashTest.php`:
```php
<?php
declare(strict_types=1);

use App\Support\Flash;
use PHPUnit\Framework\TestCase;

final class FlashTest extends TestCase
{
    protected function setUp(): void
    {
        $_SESSION = [];
    }

    public function testAddAndPullOnce(): void
    {
        Flash::add('success', 'Tersimpan');
        Flash::add('error', 'Gagal');
        $this->assertCount(2, Flash::pull());
        $this->assertSame([], Flash::pull()); // sekali tampil
    }
}
```

`tests/ViewTest.php`:
```php
<?php
declare(strict_types=1);

use App\Support\View;
use PHPUnit\Framework\TestCase;

final class ViewTest extends TestCase
{
    public function testRenderWithLayout(): void
    {
        // templates/test/partial.php & layout ada di fixtures
        $html = View::render('test/partial', ['name' => '<b>'], 'test/layout');
        $this->assertStringContainsString('LAYOUT', $html);
        $this->assertStringContainsString('&lt;b&gt;', $html); // data sudah di-escape oleh partial
        $this->assertStringContainsString('content-tes', $html);
    }
}
```
Buat fixture `templates/test/layout.php`: `<?php /* test */ ?>LAYOUT<?= $content ?>` dan `templates/test/partial.php`: `<div>content-tes<?= e($name) ?></div>`.

- [ ] **Step 2: Run → FAIL**

Run: `composer test` → class not found.

- [ ] **Step 3: Implement**

`src/Support/View.php`:
```php
<?php
declare(strict_types=1);

namespace App\Support;

final class View
{
    public static function render(string $template, array $data = [], string $layout = 'public/layout'): string
    {
        $base = Container::baseDir() . '/templates/';
        $file = $base . $template . '.php';
        if (!is_readable($file)) {
            throw new \RuntimeException("Template tidak ditemukan: $template");
        }
        $renderer = static function (string $__file, array $__data): string {
            extract($__data, EXTR_SKIP);
            ob_start();
            include $__file;
            return (string) ob_get_clean();
        };
        $content = $renderer($file, $data);
        $layoutFile = $base . $layout . '.php';
        if (!is_readable($layoutFile)) {
            return $content;
        }
        return $renderer($layoutFile, $data + ['content' => $content]);
    }
}
```

`src/Support/Flash.php`:
```php
<?php
declare(strict_types=1);

namespace App\Support;

final class Flash
{
    public static function add(string $type, string $message): void
    {
        $_SESSION['_flash'][] = ['type' => $type, 'message' => $message];
    }

    public static function pull(): array
    {
        $items = $_SESSION['_flash'] ?? [];
        unset($_SESSION['_flash']);
        return $items;
    }
}
```

`src/Support/Validator.php`:
```php
<?php
declare(strict_types=1);

namespace App\Support;

final class Validator
{
    /** @return array<string,string> field => pesan */
    public static function validate(array $input, array $rules): array
    {
        $errors = [];
        foreach ($rules as $field => $ruleString) {
            $value = $input[$field] ?? null;
            $value = is_string($value) ? trim($value) : $value;
            foreach (explode('|', $ruleString) as $rule) {
                [$name, $arg] = array_pad(explode(':', $rule, 2), 2, null);
                if ($value === null || $value === '') {
                    if ($name === 'required') {
                        $errors[$field] = ucfirst($field) . ' wajib diisi.';
                    }
                    continue; // rule lain skip jika kosong & bukan required
                }
                switch ($name) {
                    case 'required':
                        break;
                    case 'email':
                        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                            $errors[$field] = 'Format email tidak valid.';
                        }
                        break;
                    case 'min':
                        if (mb_strlen((string) $value) < (int) $arg) {
                            $errors[$field] = 'Minimal ' . $arg . ' karakter.';
                        }
                        break;
                    case 'max':
                        if (mb_strlen((string) $value) > (int) $arg) {
                            $errors[$field] = 'Maksimal ' . $arg . ' karakter.';
                        }
                        break;
                    case 'year':
                        if (!ctype_digit((string) $value) || (int) $value < 1990 || (int) $value > 2100) {
                            $errors[$field] = 'Tahun harus antara 1990-2100.';
                        }
                        break;
                    case 'int':
                        if (!ctype_digit((string) $value)) {
                            $errors[$field] = 'Harus berupa angka.';
                        }
                        break;
                }
            }
        }
        return $errors;
    }
}
```

`src/Support/Uploader.php`:
```php
<?php
declare(strict_types=1);

namespace App\Support;

use Slim\Psr7\UploadedFile;

final class Uploader
{
    public const MAX_BYTES = 2097152; // 2MB

    /** @var array<string,string> mime => ekstensi */
    public const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];

    public static function store(UploadedFile $file, string $subdir = ''): string
    {
        if ($file->getError() === UPLOAD_ERR_NO_FILE) {
            throw new \RuntimeException('Tidak ada file yang diupload.');
        }
        if ($file->getError() !== UPLOAD_ERR_OK) {
            throw new \RuntimeException('Upload gagal (kode ' . $file->getError() . ').');
        }
        if ($file->getSize() > self::MAX_BYTES) {
            throw new \RuntimeException('Ukuran file maksimal 2 MB.');
        }
        $tmp = $file->getStream()->getMetadata('uri');
        $mime = (new \finfo(FILEINFO_MIME_TYPE))->file($tmp);
        if (!isset(self::ALLOWED[$mime])) {
            throw new \RuntimeException('Format file harus JPG, PNG, atau WebP.');
        }
        if (@getimagesize($tmp) === false) {
            throw new \RuntimeException('File gambar rusak atau tidak valid.');
        }
        $name = bin2hex(random_bytes(16)) . '.' . self::ALLOWED[$mime];
        $destDir = self::dir($subdir);
        if (!is_dir($destDir)) {
            mkdir($destDir, 0755, true);
        }
        $file->moveTo($destDir . '/' . $name);
        return $name;
    }

    public static function delete(?string $filename, string $subdir = ''): void
    {
        if ($filename === null || $filename === '' || str_contains($filename, '/') || str_contains($filename, '\\') || str_contains($filename, '..')) {
            return;
        }
        $path = self::dir($subdir) . '/' . $filename;
        if (is_file($path)) {
            @unlink($path);
        }
    }

    public static function dir(string $subdir = ''): string
    {
        $base = Container::baseDir() . '/uploads';
        return $subdir === '' ? $base : $base . '/' . trim($subdir, '/');
    }
}
```

Stub layout (dilengkapi Task 5/10): `templates/public/layout.php` minimal `<!DOCTYPE html><html><body><?= $content ?></body></html>`; `templates/admin/layout.php` serupa.

- [ ] **Step 4: Run → PASS**

Run: `composer test` → semua PASS.

- [ ] **Step 5: Commit**

```powershell
git add -A
git commit -m "feat: view renderer, flash, validator, image uploader with tests"
```

---

### Task 5: CSRF middleware + Auth (login/logout/throttle) + admin layout

**Files:**
- Create: `src/Middleware/CsrfCheck.php`, `src/Middleware/AuthRequired.php`, `src/Repositories/AdminRepository.php`, `src/Repositories/LoginAttemptRepository.php`, `src/Controllers/Admin/AuthController.php`, `templates/admin/login.php`
- Modify: `src/bootstrap.php` (routes admin login + middleware), `templates/admin/layout.php` (sidebar/topbar lengkap dari prototipe)
- Test: `tests/ThrottleTest.php`

**Interfaces:**
- Consumes: Task 2 bootstrap, Task 4 View/Flash/Validator.
- Produces: route `GET/POST /admin/login`, `POST /admin/logout`; middleware `CsrfCheck` (global), `AuthRequired` (group `/admin` kecuali login — daftarkan AuthRequired di group khusus); session `admin_id`.

- [ ] **Step 1: Failing test throttle logic**

`tests/ThrottleTest.php` — logika throttle murni di `LoginAttemptRepository::isBlocked`:
```php
<?php
declare(strict_types=1);

use App\Repositories\LoginAttemptRepository;
use PHPUnit\Framework\TestCase;

final class ThrottleTest extends TestCase
{
    public function testIsBlockedPure(): void
    {
        $now = new DateTimeImmutable('2026-10-10 12:00:00');
        $recent = [
            ['attempted_at' => '2026-10-10 11:50:00'],
            ['attempted_at' => '2026-10-10 11:55:00'],
            ['attempted_at' => '2026-10-10 11:58:00'],
            ['attempted_at' => '2026-10-10 11:59:00'],
            ['attempted_at' => '2026-10-10 11:59:30'],
        ];
        // static pure helper supaya tanpa PDO:
        $this->assertTrue(LoginAttemptRepository::blockedFromAttempts($recent, 5, 15, $now));
        $this->assertFalse(LoginAttemptRepository::blockedFromAttempts($recent, 6, 15, $now));
        $older = [['attempted_at' => '2026-10-10 11:00:00']];
        $this->assertFalse(LoginAttemptRepository::blockedFromAttempts($older, 5, 15, $now));
    }
}
```

- [ ] **Step 2: Run → FAIL**

- [ ] **Step 3: Implement repositori + middleware + controller**

`src/Repositories/LoginAttemptRepository.php`:
```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use PDO;

final class LoginAttemptRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /** @param array<int,array{attempted_at:string}> $attempts */
    public static function blockedFromAttempts(array $attempts, int $max, int $windowMinutes, \DateTimeImmutable $now): bool
    {
        $cutoff = $now->modify("-{$windowMinutes} minutes");
        $count = 0;
        foreach ($attempts as $row) {
            if (new \DateTimeImmutable($row['attempted_at']) >= $cutoff) {
                $count++;
            }
        }
        return $count >= $max;
    }

    public function isBlocked(string $ip, int $max = 5, int $windowMinutes = 15): bool
    {
        $stmt = $this->pdo->prepare('SELECT attempted_at FROM login_attempts WHERE ip = ? AND attempted_at >= (NOW() - INTERVAL ? MINUTE)');
        $stmt->execute([$ip, $windowMinutes]);
        return self::blockedFromAttempts($stmt->fetchAll(PDO::FETCH_ASSOC), $max, $windowMinutes, new \DateTimeImmutable('now'));
    }

    public function record(string $ip): void
    {
        $this->pdo->prepare('INSERT INTO login_attempts (ip, attempted_at) VALUES (?, NOW())')->execute([$ip]);
    }

    public function clear(string $ip): void
    {
        $this->pdo->prepare('DELETE FROM login_attempts WHERE ip = ?')->execute([$ip]);
    }
}
```

`src/Repositories/AdminRepository.php`:
```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use PDO;

final class AdminRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function findByUsername(string $username): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM admins WHERE username = ?');
        $stmt->execute([$username]);
        return $stmt->fetch() ?: null;
    }

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM admins WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    public function updatePassword(int $id, string $hash): void
    {
        $this->pdo->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')->execute([$hash, $id]);
    }
}
```

`src/Middleware/CsrfCheck.php`:
```php
<?php
declare(strict_types=1);

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\RequestHandlerInterface as Handler;

final class CsrfCheck
{
    public function __invoke(Request $request, Handler $handler): Response
    {
        if (strtoupper($request->getMethod()) === 'POST') {
            $body = $request->getParsedBody();
            $token = is_array($body) ? (string) ($_POST['_token'] ?? '') : '';
            if ($token === '' || !hash_equals($_SESSION['_csrf'] ?? '', $token)) {
                $response = new \Slim\Psr7\Response(400);
                $response->getBody()->write('CSRF token tidak valid.');
                return $response;
            }
        }
        return $handler->handle($request);
    }
}
```

`src/Middleware/AuthRequired.php`:
```php
<?php
declare(strict_types=1);

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\RequestHandlerInterface as Handler;

final class AuthRequired
{
    public function __invoke(Request $request, Handler $handler): Response
    {
        if (empty($_SESSION['admin_id'])) {
            $response = new \Slim\Psr7\Response(302);
            return $response->withHeader('Location', '/admin/login');
        }
        return $handler->handle($request);
    }
}
```

`src/Controllers/Admin/AuthController.php`:
```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Repositories\AdminRepository;
use App\Repositories\LoginAttemptRepository;
use App\Support\Flash;
use App\Support\Validator;
use App\Support\View;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

final class AuthController
{
    public function __construct(
        private AdminRepository $admins,
        private LoginAttemptRepository $attempts,
    ) {
    }

    public function showLogin(Request $request, Response $response): Response
    {
        if (!empty($_SESSION['admin_id'])) {
            $response = $response->withHeader('Location', '/admin');
            return $response;
        }
        $response->getBody()->write(View::render('admin/login', [
            'pageTitle' => 'Login',
            'layout' => false,
        ], 'admin/login-layout'));
        return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
    }

    public function login(Request $request, Response $response): Response
    {
        $data = (array) $request->getParsedBody();
        $errors = Validator::validate($data, ['username' => 'required', 'password' => 'required']);
        $ip = $request->getServerParams()['REMOTE_ADDR'] ?? '0.0.0.0';

        if ($this->attempts->isBlocked($ip)) {
            Flash::add('error', 'Terlalu banyak percobaan. Coba lagi 15 menit lagi.');
            return $response->withHeader('Location', '/admin/login');
        }

        if ($errors) {
            Flash::add('error', 'Username dan password wajib diisi.');
            return $response->withHeader('Location', '/admin/login');
        }

        $admin = $this->admins->findByUsername(trim($data['username']));
        if (!$admin || !password_verify($data['password'], $admin['password_hash'])) {
            $this->attempts->record($ip);
            Flash::add('error', 'Username atau password salah.');
            return $response->withHeader('Location', '/admin/login');
        }

        $this->attempts->clear($ip);
        session_regenerate_id(true);
        $_SESSION['admin_id'] = (int) $admin['id'];
        Flash::add('success', 'Selamat datang kembali, ' . $admin['display_name'] . '.');
        return $response->withHeader('Location', '/admin');
    }

    public function logout(Request $request, Response $response): Response
    {
        unset($_SESSION['admin_id']);
        session_regenerate_id(true);
        return $response->withHeader('Location', '/admin/login');
    }
}
```

`templates/admin/login.php` — halaman login berdiri sendiri (di-render dengan layout `admin/login-layout`): kartu login di tengah layar bergaya prototipe (font Inter/Serif/Montserrat CDN + Tailwind CDN + konfigurasi sama seperti `admin.html`), badge rose "Admin Panel", form POST `/admin/login` dengan `_token`, input username + password, flash message, footer kecil. Desain mengikuti gaya `admin.html` (rounded-2xl, border gray-200, shadow, tombol `bg-gray-900 hover:bg-rose-600`).

Buat `templates/admin/login-layout.php`: shell HTML lengkap (head + Tailwind CDN + config + font + Lucide) tanpa sidebar, `<?= $content ?>`, `lucide.createIcons()`.

`templates/admin/layout.php` — porting sidebar + topbar + footer dari `admin.html`:
- Sidebar: brand, nav link real: Dashboard `/admin`, Projects `/admin/projects` (+badge jumlah dari `$data['projectCount'] ?? 0`), Services `/admin/services`, Messages `/admin/messages` (+badge `$data['unreadCount'] ?? 0`), Analytics `/admin/analytics`; grup System: Settings `/admin/settings`; kartu "View Live Site" ke `/`.
- Active state: `str_starts_with($path, $navPath)` → tambah class `active`.
- Topbar: hamburger (Alpine `x-data` buka sidebar), search statis, profil `<?= e($data['adminName'] ?? 'Admin') ?>`, form logout POST tersembunyi dengan `_token`.
- Footer: `© 2026 <?= e($data['adminName'] ?? 'Masum') ?> — Admin Panel v1.0`.
- Alpine: `x-data="{ open: false }"` untuk sidebar mobile + overlay (gaya `admin.html`).
- `lucide.createIcons()` di akhir.
- Layout menerima `$data['flash']` — render flash toast/pill di atas konten: loop `Flash::pull()`.

- [ ] **Step 4: Wiring bootstrap**

Di `src/bootstrap.php`, setelah `$app = AppFactory::create();` dan sebelum return:

```php
use App\Controllers\Admin\AuthController;
use App\Middleware\AuthRequired;
use App\Middleware\CsrfCheck;
use App\Repositories\AdminRepository;
use App\Repositories\LoginAttemptRepository;
use App\Support\Container;

$app->add(new CsrfCheck());

$auth = new AuthController(
    new AdminRepository(Container::pdo()),
    new LoginAttemptRepository(Container::pdo()),
);

$app->get('/admin/login', [$auth, 'showLogin']);
$app->post('/admin/login', [$auth, 'login']);
$app->post('/admin/logout', [$auth, 'logout']);

$app->group('/admin', function ($group) {
    $group->add(new AuthRequired());
    // route admin lain menyusul di task berikutnya
});
```

- [ ] **Step 5: Verifikasi manual (smoke)**

```powershell
composer test
php -S localhost:8000 index.php
curl.exe -s -o NUL -w "%{http_code}" http://localhost:8000/admin           # 302 → /admin/login
curl.exe -s http://localhost:8000/admin/login                              # 200, form login tampil
# login salah 5x berturut → pesan throttle; login benar (admin / ChangeMe_2026!) → 302 ke /admin (dashboard 404 sementara — belum ada route, OK)
```

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: auth login/logout with csrf and throttle, admin layout"
```

---

### Task 6: Projects CRUD + upload cover + activity

**Files:**
- Create: `src/Repositories/ProjectRepository.php`, `src/Repositories/ActivityRepository.php`, `src/Controllers/Admin/ProjectController.php`, `templates/admin/projects.php`, `templates/admin/project-form.php`
- Modify: `src/bootstrap.php` (routes), `src/Support/Container.php` tidak berubah (repos dibuat inline di bootstrap)

**Interfaces:**
- Consumes: View, Flash, Validator, Uploader, CsrfCheck (global), AuthRequired (group), ActivityRepository.
- Produces: ProjectRepository persis Core Contracts; routes `/admin/projects*`.

- [ ] **Step 1: ProjectRepository + ActivityRepository**

`src/Repositories/ProjectRepository.php` (JSON encode/decode, pagination):
```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use PDO;

final class ProjectRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function paginate(int $page, int $perPage = 10): array
    {
        $total = (int) $this->pdo->query('SELECT COUNT(*) FROM projects')->fetchColumn();
        $pages = max(1, (int) ceil($total / $perPage));
        $page = max(1, min($page, $pages));
        $stmt = $this->pdo->prepare('SELECT * FROM projects ORDER BY sort_order ASC, id DESC LIMIT ' . $perPage . ' OFFSET ' . (($page - 1) * $perPage));
        $stmt->execute();
        $items = array_map([$this, 'decode'], $stmt->fetchAll(PDO::FETCH_ASSOC));
        return ['items' => $items, 'total' => $total, 'pages' => $pages, 'page' => $page];
    }

    public function allLive(): array
    {
        $rows = $this->pdo->query("SELECT * FROM projects WHERE status = 'live' ORDER BY sort_order ASC, id ASC")->fetchAll(PDO::FETCH_ASSOC);
        return array_map([$this, 'decode'], $rows);
    }

    public function latest(int $limit = 5): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM projects ORDER BY updated_at DESC LIMIT ' . $limit);
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM projects WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->decode($row) : null;
    }

    public function create(array $d): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO projects (title, subtitle, year, tech_stack, status, description, highlights, cover_image, icon, sort_order)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $d['title'], $d['subtitle'], $d['year'],
            json_encode($d['tech_stack'], JSON_UNESCAPED_UNICODE),
            $d['status'], $d['description'],
            json_encode($d['highlights'], JSON_UNESCAPED_UNICODE),
            $d['cover_image'] ?? null, $d['icon'] ?? null, $d['sort_order'],
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $d): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE projects SET title=?, subtitle=?, year=?, tech_stack=?, status=?, description=?, highlights=?, cover_image=?, icon=?, sort_order=? WHERE id=?'
        );
        $stmt->execute([
            $d['title'], $d['subtitle'], $d['year'],
            json_encode($d['tech_stack'], JSON_UNESCAPED_UNICODE),
            $d['status'], $d['description'],
            json_encode($d['highlights'], JSON_UNESCAPED_UNICODE),
            $d['cover_image'] ?? null, $d['icon'] ?? null, $d['sort_order'], $id,
        ]);
    }

    public function setStatus(int $id, string $status): void
    {
        $this->pdo->prepare('UPDATE projects SET status = ? WHERE id = ?')->execute([$status, $id]);
    }

    public function delete(int $id): void
    {
        $this->pdo->prepare('DELETE FROM projects WHERE id = ?')->execute([$id]);
    }

    public function countAll(): int
    {
        return (int) $this->pdo->query('SELECT COUNT(*) FROM projects')->fetchColumn();
    }

    private function decode(array $row): array
    {
        $row['tech_stack'] = json_decode($row['tech_stack'] ?? '[]', true) ?: [];
        $row['highlights'] = json_decode($row['highlights'] ?? '[]', true) ?: [];
        return $row;
    }
}
```

`src/Repositories/ActivityRepository.php`:
```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use PDO;

final class ActivityRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function add(string $title, string $description, ?string $url = null): void
    {
        $this->pdo->prepare('INSERT INTO activities (title, description, url) VALUES (?, ?, ?)')
            ->execute([$title, $description, $url]);
    }

    public function latest(int $limit = 8): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM activities ORDER BY created_at DESC, id DESC LIMIT ' . $limit);
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}
```

- [ ] **Step 2: ProjectController**

`src/Controllers/Admin/ProjectController.php` — method: `index` (paginate + render), `create` GET/POST, `edit` GET/POST, `toggle` POST, `delete` POST.

Aturan validasi POST: `title required min:3 max:160`, `subtitle max:200`, `year required|year`, `status required` (in live/draft), `description required|min:10`, `highlights_lines required|min:1` (textarea, split per baris, buang baris kosong), `tech comma required|min:1` (split koma), `sort_order int optional default 0`, `icon` optional in-list.

Daftar icon yang diizinkan: `['book-open','mic','bar-chart-2','monitor-smartphone','cpu','file-code','sun','arrow-up-right','mail']`.

Create POST flow:
```php
$errors = Validator::validate($data, [/* rules di atas */]);
$cover = null;
if (!$errors && ($_FILES['cover_image']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
    try {
        $cover = Uploader::store(new \Slim\Psr7\UploadedFile($_FILES['cover_image']));
    } catch (\RuntimeException $e) {
        $errors['cover_image'] = $e->getMessage();
    }
}
if ($errors) { /* render ulang form dengan $old=$_POST, $errors */ }
$id = $this->projects->create([...]); // cover_image=$cover
$this->activities->add('Project baru ditambahkan', $title, '/admin/projects/' . $id . '/edit');
Flash::add('success', 'Project berhasil ditambahkan.');
return redirect /admin/projects;
```
Edit POST: jika upload baru → `Uploader::delete($old['cover_image'])` setelah sukses update. Delete POST: hapus cover file + row + activity `Project dihapus`.

Parsing input:
```php
$tech = array_values(array_filter(array_map('trim', explode(',', $data['tech comma'] ?? ''))));
$highlights = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $data['highlights_lines'] ?? '')), fn($v) => $v !== ''));
```

- [ ] **Step 3: Templates**

`templates/admin/projects.php`: header kartu "Recent Projects" + tombol Tambah (`/admin/projects/create`), tabel persis `admin.html` (kolom Project, Tahun, Tech Stack chips, Status pill, Aksi) tapi:
- baris dari `$data['projects']['items']`, cover kecil 40px (`<img>` jika `cover_image`, ikon lucide jika tidak),
- aksi: Edit (link), Toggle (POST mini-form `_token` + tombol pill), Delete (Alpine `x-data="{c:false}"` dengan `@click` ganti tombol jadi konfirmasi "Hapus?" Ya / Batal),
- pagination: link `?page=N` persis gaya `admin.html`.
Data: `['projects' => $pager, 'pageTitle' => 'Projects', 'flash handled by layout']`. Layout membutuhkan `unreadCount`/`projectCount` — isi dari bootstrap via shared closure (lihat Step 4).

`templates/admin/project-form.php`: form multipart ke URL aksi, field: title, subtitle, year, tech comma (placeholder "Next.js, AI"), status select (Draft/Live), description textarea, highlights textarea (rows=5, hint "satu per baris"), cover upload (preview jika `$project['cover_image']`), icon select, sort_order number. Error merah per field + old value.

- [ ] **Step 4: Routes di bootstrap (di dalam group `/admin`)**

```php
$projects = new ProjectController(new ProjectRepository(Container::pdo()), new ActivityRepository(Container::pdo()));
$group->get('/projects', [$projects, 'index']);
$group->get('/projects/create', [$projects, 'create']);
$group->post('/projects/create', [$projects, 'createSubmit']);
$group->get('/projects/{id}/edit', [$projects, 'edit']);
$group->post('/projects/{id}/edit', [$projects, 'editSubmit']);
$group->post('/projects/{id}/toggle', [$projects, 'toggle']);
$group->post('/projects/{id}/delete', [$projects, 'delete']);
```

**Shared layout data** — di dalam group `/admin`, tambahkan middleware closure yang menyuntikkan data ke request atribut, ATAU lebih sederhana: layout membaca langsung dari repositori statis. Keputusan: helper `App\Support\Admin::layoutData(): array`:
```php
final class Admin {
    public static function layoutData(): array {
        $pdo = Container::pdo();
        return [
            'adminName' => ($_SESSION['admin_name'] ?? null) ?? 'Admin',
            'unreadCount' => (new MessageRepository($pdo))->unreadCount(),
            'projectCount' => (new ProjectRepository($pdo))->countAll(),
            'pageTitle' => '',
        ];
    }
}
```
Setiap controller meng-merge `Admin::layoutData()` + data halaman sebelum `View::render`. Untuk Task 5 `adminName`: set `$_SESSION['admin_name'] = $admin['display_name']` saat login sukses (tambahkan di AuthController login — 1 baris).

- [ ] **Step 5: Smoke**

```powershell
php -S localhost:8000 index.php
# login, lalu:
curl.exe -s http://localhost:8000/admin/projects                        # 200 tabel
# buat project via form (curl multipart atau browser): cover PNG kecil 100x100
# cek baris muncul, edit, toggle status, delete; cek file uploads/ hilang saat delete
# cek activities: php -r "...SELECT * FROM activities"
```

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: projects CRUD with cover upload and activity log"
```

---

### Task 7: Services CRUD

**Files:**
- Create: `src/Repositories/ServiceRepository.php`, `src/Controllers/Admin/ServiceController.php`, `templates/admin/services.php`, `templates/admin/service-form.php`
- Modify: `src/bootstrap.php`

**Interfaces:**
- Consumes: pola Task 6 (Validator, Flash, View, Admin::layoutData, ActivityRepository).
- Produces: ServiceRepository persis Core Contracts; routes `/admin/services*`.

- [ ] **Step 1: ServiceRepository** — sama pola ProjectRepository tanpa JSON `tech_stack`, kolom `category` (validasi in: development/automation/data), `features` JSON.

```php
public function allActive(): array  // ORDER BY sort_order ASC, id ASC
public function paginate(int $page, int $perPage = 10): array
public function find(int $id): ?array   // features decoded
public function create(array $data): int
public function update(int $id, array $data): void
public function delete(int $id): void
```
Kolom: title, category, icon, description, features (json), sort_order.

- [ ] **Step 2: ServiceController** — persis pola ProjectController tanpa upload. Validasi: title required min:3 max:160, category required, icon required, description required min:10, features_lines required (split baris), sort_order int.
Activity: `Service baru ditambahkan` / `Service diperbarui` / `Service dihapus`.

- [ ] **Step 3: Templates** `services.php` (tabel: Service, Kategori pill, Icon, Fitur count, Aksi) + `service-form.php` (category select: Development/Automation/Data).

- [ ] **Step 4: Routes**

```php
$services = new ServiceController(new ServiceRepository(Container::pdo()), new ActivityRepository(Container::pdo()));
$group->get('/services', [$services, 'index']);
$group->get('/services/create', [$services, 'create']);
$group->post('/services/create', [$services, 'createSubmit']);
$group->get('/services/{id}/edit', [$services, 'edit']);
$group->post('/services/{id}/edit', [$services, 'editSubmit']);
$group->post('/services/{id}/delete', [$services, 'delete']);
```

- [ ] **Step 5: Smoke** — tambah 2 service (category development + automation), tampil di tabel, edit, delete. `composer test` tetap hijau.

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: services CRUD with category and features list"
```

---

### Task 8: Messages CRUD

**Files:**
- Create: `src/Repositories/MessageRepository.php`, `src/Controllers/Admin/MessageController.php`, `templates/admin/messages.php`
- Modify: `src/bootstrap.php`

**Interfaces:**
- Produces: MessageRepository persis Core Contracts.

- [ ] **Step 1: MessageRepository**

```php
public function paginate(int $page, int $perPage = 10): array
// SELECT * FROM messages ORDER BY is_read ASC, created_at DESC, id DESC LIMIT/OFFSET + COUNT
public function latest(int $limit = 5): array   // ORDER BY created_at DESC
public function create(array $data): int        // sender_name, email, body
public function toggleRead(int $id): void       // UPDATE messages SET is_read = 1 - is_read
public function delete(int $id): void
public function unreadCount(): int              // COUNT WHERE is_read = 0
public function countAll(): int
```

- [ ] **Step 2: MessageController**
- `index`: paginate + data form tambah.
- `createSubmit`: validasi sender_name required min:2 max:120, email required|email, body required|min:2 → activity `Pesan baru dicatat` desc sender → redirect.
- `toggleRead`, `delete` (activity `Pesan dihapus`).

- [ ] **Step 3: Template `messages.php`** — daftar persis `admin.html` (avatar inisial rose/gray, badge "Baru" jika unread, snippet, waktu relatif Indonesia — tulis helper kecil `App\Support\Time::since(string $datetime): string` ("2 menit lalu", "1 jam lalu", "Kemarin", tanggal), toggle read (POST), delete (Alpine confirm), form tambah pesan (card terpisah di bawah / di kanan).

`src/Support/Time.php`:
```php
final class Time {
    public static function since(string $datetime): string {
        $diff = (new DateTimeImmutable('now'))->getTimestamp() - (new DateTimeImmutable($datetime))->getTimestamp();
        if ($diff < 60) return 'Baru saja';
        if ($diff < 3600) return intdiv($diff, 60) . ' menit lalu';
        if ($diff < 86400) return intdiv($diff, 3600) . ' jam lalu';
        if ($diff < 172800) return 'Kemarin';
        return (new DateTimeImmutable($datetime))->format('d M Y');
    }
}
```
Unit test kecil `tests/TimeTest.php` (fixed now? — jika sulit, test hanya format masa lalu 90 hari → return format d M Y; buat test untuk rentang jam dengan datetime relatif `-10 minutes`).

- [ ] **Step 4: Routes**

```php
$messages = new MessageController(new MessageRepository(Container::pdo()), new ActivityRepository(Container::pdo()));
$group->get('/messages', [$messages, 'index']);
$group->post('/messages/create', [$messages, 'createSubmit']);
$group->post('/messages/{id}/read', [$messages, 'toggleRead']);
$group->post('/messages/{id}/delete', [$messages, 'delete']);
```

- [ ] **Step 5: Smoke** — tambah 3 pesan manual, toggle read, badge sidebar berkurang, delete.

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: messages management with read toggle and relative time"
```

---

### Task 9: Settings (semua key + upload + password + live toggle)

**Files:**
- Create: `src/Repositories/SettingRepository.php`, `src/Controllers/Admin/SettingController.php`, `templates/admin/settings.php`
- Modify: `src/bootstrap.php`, AuthController (opsional: setelah ganti password tetap login)

**Interfaces:**
- Produces: SettingRepository persis Core Contracts. Key lengkap ada di spec §7.

- [ ] **Step 1: SettingRepository**

```php
public function all(): array
// SELECT `key`, value FROM settings → map; cache di properti $cache
public function get(string $key, ?string $default = null): string
public function setMany(array $pairs): void
// untuk tiap pair: INSERT INTO settings (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value)
public function clearCache(): void
```

- [ ] **Step 2: SettingController `show`/`save` (GET/POST satu route `/admin/settings`)**

Field POST (semua string kecuali disebut): owner_name, owner_role, available_badge, hero_line1..3, hero_subtitle, stat1_value, stat1_label, stat2_*, stat3_*, about_heading, about_p1, about_p2, focus1_label, focus1_value, focus2_*, focus3_*, contact_email (validasi email), social_twitter, social_github, social_linkedin, footer_text.

Upload (opsional, 3 field: `portrait`, `polaroid1`, `polaroid2`): jika ada file → `Uploader::store` → hapus file lama (key settings simpan filename). Jika gagal → flash error, key lama dipertahankan.

Toggle live_site: checkbox → `'1'`/`'0'`.

Ganti password (field terpisah): `current_password`, `new_password` (min 8), `confirm_password` (harus sama). Verifikasi: `password_verify(current, hash)` — jika salah: error. Sukses: `AdminRepository::updatePassword` + activity + flash.

Validasi ringan: owner_name required; semua field lain default `''` aman.

Default saat settings kosong (untuk tampilan admin form): `SettingRepository::defaults(): array` mengembalikan fallback value (nama "Masum", email "hello@masum.dev", dst) — merge `array_merge($defaults, $saved)`.

Activity: `Pengaturan diperbarui` / `Password diganti`.

- [ ] **Step 3: Template `settings.php`** — form multipart besar, section-card: Profil, Hero, Stats, About + Focus, Kontak & Sosial, Foto (3 upload dengan preview thumbnail saat ini), Website (toggle live_site Alpine switch persis `admin.html`), Keamanan (ganti password). Semua input punya `value="<?= e($settings['key']) ?>"` + error per-field.

- [ ] **Step 4: Routes**

```php
$settings = new SettingController(new SettingRepository(Container::pdo()), new AdminRepository(Container::pdo()), new ActivityRepository(Container::pdo()));
$group->get('/settings', [$settings, 'show']);
$group->post('/settings', [$settings, 'save']);
```

- [ ] **Step 5: Smoke** — ubah owner_name, upload portrait PNG, toggle live_site off/on, ganti password (salah dulu → error; benar → sukses, login ulang dengan password baru; kembalikan password ke `ChangeMe_2026!` agar konsisten dengan docs? **TIDAK** — biarkan, catat di laporan akhir password sudah diganti).

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: settings page for all content keys, uploads, live toggle, password"
```

---

### Task 10: Halaman publik dari prototipe + VisitorTracker + maintenance

**Files:**
- Create: `src/Controllers/PublicController.php`, `src/Middleware/VisitorTracker.php`, `templates/public/{home,nav,hero,marquee,work,about,services,contact,footer,maintenance,error500,error404}.php`
- Modify: `src/bootstrap.php` (route `GET /` dengan middleware VisitorTracker)

**Interfaces:**
- Consumes: SettingRepository, ProjectRepository::allLive(), ServiceRepository::allActive(), View, e().
- Produces: `GET /` mengembalikan halaman lengkap; cookie `pv_id` + 1 baris page_views per kunjungan.

- [ ] **Step 1: VisitorTracker**

```php
<?php
declare(strict_types=1);

namespace App\Middleware;

use App\Repositories\PageViewRepository;
use App\Support\Container;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\RequestHandlerInterface as Handler;

final class VisitorTracker
{
    public function __invoke(Request $request, Handler $handler): Response
    {
        $id = $_COOKIE['pv_id'] ?? '';
        if (!preg_match('/^[a-f0-9]{32}$/', $id)) {
            $id = bin2hex(random_bytes(16));
            setcookie('pv_id', $id, [
                'expires' => time() + 31536000,
                'path' => '/',
                'samesite' => 'Lax',
                'httponly' => true,
            ]);
        }
        try {
            (new PageViewRepository(Container::pdo()))->log(
                $id,
                '/',
                $request->getHeaderLine('Referer') ?: null
            );
        } catch (\Throwable $e) {
            error_log('[tracker] ' . $e->getMessage());
        }
        return $handler->handle($request);
    }
}
```

- [ ] **Step 2: PageViewRepository minimal** (log dulu; agregat di Task 11):

```php
public function log(string $visitorId, string $path, ?string $referer): void
{
    $this->pdo->prepare('INSERT INTO page_views (visitor_id, path, referer) VALUES (?, ?, ?)')
        ->execute([$visitorId, $path, $referer !== null ? mb_substr($referer, 0, 190) : null]);
}
```

- [ ] **Step 3: PublicController**

```php
final class PublicController
{
    public function __construct(
        private SettingRepository $settings,
        private ProjectRepository $projects,
        private ServiceRepository $services,
    ) {}

    public function home(Request $request, Response $response): Response
    {
        $settings = $this->settings->all();
        $isAdmin = !empty($_SESSION['admin_id']);
        if (($settings['live_site'] ?? '1') !== '1' && !$isAdmin) {
            $response = $response->withStatus(503);
            $response->getBody()->write(View::render('public/maintenance', [
                'pageTitle' => 'Coming Soon',
                'settings' => $settings,
            ]));
            return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
        }
        $response->getBody()->write(View::render('public/home', [
            'pageTitle' => ($settings['owner_name'] ?? 'Masum') . ' — ' . ($settings['owner_role'] ?? 'Web Developer'),
            'settings' => $settings,
            'projects' => $this->projects->allLive(),
            'services' => $this->services->allActive(),
        ]));
        return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
    }
}
```

- [ ] **Step 4: Templates publik — porting dari `portofolio.html`**

`templates/public/layout.php`: salin `<head>` + Tailwind CDN + `tailwind.config` (font + `base: '#F9F9F9'`) + font Google + Lucide + blok `<style>` (hero-grid, marquee-mask, keyframes) dari `portofolio.html` baris 1–127; body class sama; `<?= $content ?>`; `lucide.createIcons()`.

`templates/public/nav.php`: baris 135–181 — href sosial dari `$settings['social_*']`, `#contact` tetap.

`templates/public/hero.php`: baris 188–281:
- badge: `e($settings['available_badge'])`
- H1: line1, line2 italic, line3 gradient dari settings
- subtitle: `e($settings['hero_subtitle'])`
- stats: 3× `statN_value/label`
- portrait: `!empty($settings['portrait']) ? '<img src="/uploads/' . e($settings['portrait']) . '">' : placeholder gradient box`
- floating card & tombol statis.

`templates/public/marquee.php`: baris 286–425 100% statis (SVG React/Next/Tailwind/Supabase/Pinecone/Python/Vercel), duplikasi set untuk loop.

`templates/public/work.php`: baris 430–559 → loop:
```php
<?php foreach ($projects as $i => $p): ?>
  <article class="sticky" style="top: <?= 96 + $i * 16 ?>px">
    ... year badge e($p['year']), tag = e(implode(' · ', $p['tech_stack'])),
    h3 e($p['title']), p e($p['description']),
    highlights loop e($h),
    visual: cover_image ? <img> : <i data-lucide="<?= e($p['icon'] ?: 'file-code') ?>"></i>
  </article>
<?php endforeach; ?>
```

`templates/public/about.php`: baris 564–638 — heading/paragraf/focus dari settings; polaroid pakai `$settings['polaroid1']`/`polaroid2` (jika kosong, pakai foto placeholder gradient).

`templates/public/services.php`: baris 643–732 — filter button Alpine:
```html
<div x-data="{ filter: 'all' }">
  <button @click="filter='all'" :class="filter==='all' ? activeClass : idleClass">All Services</button>
  ... (development, automation, data)
  <?php foreach ($services as $s): ?>
    <article x-show="filter==='all' || filter==='<?= e($s['category']) ?>'"> ... icon, title, description, features loop ... </article>
  <?php endforeach; ?>
</div>
```
(Class ternyata statis dipisah: `$activeClass`/`$idleClass` string di PHP.)

`templates/public/contact.php`: baris 737–772 — mailto `e($settings['contact_email'])`, tombol statis.

`templates/public/footer.php`: baris 779–805 — `e($settings['footer_text'])`, sosial dari settings.

`templates/public/home.php`: `<?php include nav.php ?>` ... — panggil partial berurutan dengan extract data sama (karena View render home saja, home.php include partial dengan `$settings`/`$projects`/`$services` di scope).

`templates/public/maintenance.php` + `error500.php` + `error404.php`: gaya konsisten (kartu putih rounded, badge rose, heading serif, tombol kembali).

- [ ] **Step 5: Routes bootstrap**

```php
use App\Controllers\PublicController;
use App\Middleware\VisitorTracker;

$public = new PublicController(
    new SettingRepository(Container::pdo()),
    new ProjectRepository(Container::pdo()),
    new ServiceRepository(Container::pdo()),
);
$app->get('/', [$public, 'home'])->add(new VisitorTracker());
```

- [ ] **Step 6: Smoke + verifikasi tracking**

```powershell
php -S localhost:8000 index.php
curl.exe -s -c jar.txt http://localhost:8000/ > page.html
curl.exe -s -b jar.txt http://localhost:8000/ > page2.html
php -r "require 'vendor/autoload.php'; App\Support\Env::load('.env'); var_dump(App\Container::pdo()->query('SELECT COUNT(*) FROM page_views')->fetchColumn());"
# bandingkan page.html dengan portofolio.html secara visual (buka di browser)
```

- [ ] **Step 7: Commit**

```powershell
git add -A
git commit -m "feat: public portfolio page from prototype with visitor tracking"
```

---

### Task 11: Analytics (agregat + halaman + partial chart)

**Files:**
- Create: `src/Controllers/Admin/AnalyticsController.php`, `templates/admin/analytics.php`, `templates/admin/_traffic_chart.php`
- Modify: `src/Repositories/PageViewRepository.php` (tambah agregat), `src/bootstrap.php`

**Interfaces:**
- Produces: PageViewRepository::dailyTotals/countRange/uniqueRange/topPaths persis Core Contracts. Partial chart dipakai dashboard (Task 12) + analytics: `View::render('admin/_traffic_chart', ['series'=>array,'days'=>int])`.

- [ ] **Step 1: Implement agregat PageViewRepository**

```php
public function dailyTotals(int $days): array
{
    $days = max(1, min(90, $days));
    $stmt = $this->pdo->prepare(
        'SELECT DATE(created_at) AS d, COUNT(*) AS views, COUNT(DISTINCT visitor_id) AS uniques
         FROM page_views
         WHERE created_at >= (CURDATE() - INTERVAL ? DAY)
         GROUP BY DATE(created_at)'
    );
    $stmt->execute([$days - 1]);
    $byDate = [];
    foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
        $byDate[$row['d']] = ['views' => (int) $row['views'], 'unique' => (int) $row['uniques']];
    }
    $labels = ['Min','Sen','Sel','Rab','Kam','Jum','Sab'];
    $series = [];
    $today = new \DateTimeImmutable('today');
    for ($i = $days - 1; $i >= 0; $i--) {
        $day = $today->modify("-{$i} days");
        $key = $day->format('Y-m-d');
        $series[] = [
            'date' => $key,
            'label' => $labels[(int) $day->format('w')],
            'views' => $byDate[$key]['views'] ?? 0,
            'unique' => $byDate[$key]['unique'] ?? 0,
        ];
    }
    return $series;
}

public function countRange(int $days): int
public function uniqueRange(int $days): int
// SELECT COUNT(*) / COUNT(DISTINCT visitor_id) WHERE created_at >= (CURDATE() - INTERVAL ? DAY)

public function topPaths(int $days, int $limit = 10): array
// SELECT path, COUNT(*) views, COUNT(DISTINCT visitor_id) uniques ... GROUP BY path ORDER BY views DESC LIMIT
```

- [ ] **Step 2: Partial chart `_traffic_chart.php`**

Input `$series`, `$days`. Logic:
```php
$max = 1;
foreach ($series as $s) { $max = max($max, $s['views']); }
$peak = 0; $peakVal = -1;
foreach ($series as $i => $s) { if ($s['views'] > $peakVal) { $peakVal = $s['views']; $peak = $i; } }
$step = $days <= 7 ? 1 : ($days <= 30 ? 5 : 15); // label frequency
```
Markup: persis struktur bar chart `admin.html` (flex h-56 items-end, `bar-grow`, gap, label), tiap bar `style="height: <?= round($s['views'] / $max * 100) ?>%"`; bar `views === 0` tetap minimal 2%; bar index `=== $peak && $peakVal > 0` → kelas gradient rose + label rose, selain itu `bg-gray-100 hover:bg-rose-200`. Legend + "Puncak: {tanggal} ({views})" dari peak. Filter chips link GET: `/admin?days=7|30|90` atau `/admin/analytics?days=...` — buat partial menerima `$baseUrl` (mis. `/admin/analytics`) sehingga bisa dipakai dua halaman. Chips: active = `$days`.

- [ ] **Step 3: AnalyticsController + template**

`index(Request $request, Response $response)`: `$days = (int) ($_GET['days'] ?? 30)` (clamp 7/30/90 — selain itu → 30); data: series, topPaths, totalViews, uniques30. Render `admin/analytics`: chart card (include partial), card "Top Paths" (tabel path/views/unik), card ringkasan.

- [ ] **Step 4: Route**

```php
$analytics = new AnalyticsController(new PageViewRepository(Container::pdo()));
$group->get('/analytics', [$analytics, 'index']);
```

- [ ] **Step 5: Smoke** — buka `/admin/analytics`, `/admin/analytics?days=7`, `?days=90`; bar tampil sesuai data (setelah Task 10 sudah ada beberapa page_views).

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "feat: analytics page with daily aggregates and traffic chart partial"
```

---

### Task 12: Dashboard (stat, chart, activity, recent, live toggle)

**Files:**
- Create: `src/Controllers/Admin/DashboardController.php`, `templates/admin/dashboard.php`
- Modify: `src/bootstrap.php`

**Interfaces:**
- Consumes: semua repository + `_traffic_chart` partial + `Admin::layoutData()`.

- [ ] **Step 1: DashboardController `index`**

Ambil: `countAll` projects, `unreadCount` messages, `uniqueRange(30)`, `countRange(30)`, `dailyTotals((int)($_GET['days'] ?? 7))`, `activities->latest(8)`, `projects->latest(5)`, `messages->latest(5)`, settings untuk toggle.
Render `admin/dashboard` dengan merge `Admin::layoutData()` + `pageTitle = 'Dashboard'`.

- [ ] **Step 2: Template `dashboard.php`** — porting `admin.html` bagian:
- Page header greeting: `Selamat datang kembali, <italic><?= e($settings['owner_name']) ?></italic>` + deskripsi + tombol "New Project" → `/admin/projects/create`.
- Stats grid 4 kartu: Total Projects (`$counts['projects']`), Pesan Masuk (`$counts['unread']` + sub "X belum dibaca"), Pengunjung (`uniqueRange(30)`), Page Views (`countRange(30)`).
- Main grid: chart card (include `admin/_traffic_chart` dengan `$days=7` default, baseUrl `/admin`) + Activity feed (loop `activities`, waktu `Time::since`).
- Recent Projects table mini (5, link edit) + Pesan terbaru (5, link `/admin/messages`).
- Quick Settings card: toggle `live_site` Alpine switch → POST `/admin/settings` (form mini khusus? — buat route `POST /admin/toggle-live` di SettingController: toggle live_site + redirect balik ke `/admin`). Tombol danger zone hapus — **hilangkan** (spec: Reset Semua Data tidak ada).

- [ ] **Step 3: Route + toggle-live**

```php
$group->get('/dashboard', [$dashboard, 'index']);
// PENTING: pindahkan juga redirect /admin → /dashboard:
$group->get('', function ($request, $response) { $response->getBody()->write(View::render('admin/dashboard', ...)); return $response->withHeader('Location', '/admin/dashboard'); });
```
Atau cukup `$group->get('/', fn() => redirect '/admin/dashboard')` dan route dashboard di `/admin/dashboard`. SettingController::toggleLive: `$repo->setMany(['live_site' => current === '1' ? '0' : '1'])` → redirect back (`HTTP_REFERER` atau `/admin`).

- [ ] **Step 4: Smoke** — buka `/admin` → semua card terisi data nyata; toggle live_site di dashboard mematikan halaman publik (cek `/` → 503).

- [ ] **Step 5: Commit**

```powershell
git add -A
git commit -m "feat: dashboard with stats, traffic chart, activity feed, live toggle"
```

---

### Task 13: Hardening hosting (.htaccess, .user.ini, uploads protection) + 404 + cleanup

**Files:**
- Create: `.htaccess` (root), `uploads/.htaccess`, `.user.ini`, `templates/public/error404.php`, `templates/public/error500.php` (jika Task 10 belum), `templates/admin/error404.php`
- Delete: `db.txt` (hapus dari repo — **setelah** dipastikan `.env` sudah berisi kredensial dan `.env` ter-ignore)
- Modify: `.gitignore` (cek), `README.md` singkat (cara jalankan + deploy) — boleh dibuat minimal

- [ ] **Step 1: `.htaccess` root**

```apache
Options -Indexes
DirectoryIndex index.php

<IfModule mod_rewrite.c>
  RewriteEngine On
  # file/folder nyata dilayani langsung (uploads, assets)
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule ^ index.php [QSA,L]
</IfModule>

# proteksi file sensitif
<FilesMatch "^(\.env|composer\.(json|lock)|phpunit\.xml|full\.sql|.*\.md)$">
  Require all denied
</FilesMatch>

<IfModule mod_authz_core.c>
  <DirectoryMatch "/(src|templates|vendor|migrations|bin|storage|tests|docs)(/|$)">
    Require all denied
  </DirectoryMatch>
</IfModule>
```
Catatan: Directive `DirectoryMatch` hanya boleh di `.htaccess` jika hosting mengizinkan; jika ditolak (error 500), ganti dengan file `src/.htaccess`, `templates/.htaccess`, `vendor/.htaccess`, `migrations/.htaccess`, `bin/.htaccess`, `storage/.htaccess`, `tests/.htaccess`, `docs/.htaccess` berisi `Require all denied` (buat file-file itu juga sebagai fallback — cara paling kompatibel shared hosting). **Keputusan: buat kedua-duanya; jika site 500 saat test lokal di Apache, file per-folder tetap aman.**

- [ ] **Step 2: `uploads/.htaccess`**

```apache
<IfModule mod_php.c>
  php_flag engine off
</IfModule>
<FilesMatch "\.(?i:php|phar|phtml|pl|py|cgi)$">
  Require all denied
</FilesMatch>
Options -Indexes
```

- [ ] **Step 3: `.user.ini`**

```
upload_max_filesize=2M
post_max_size=3M
memory_limit=128M
```

- [ ] **Step 4: 404 templates** — `public/error404.php` (layout publik: "404 — Page not found" + tombol home), `admin/error404.php` (shell admin sederhana: "Halaman tidak ditemukan" + link /admin).

- [ ] **Step 5: Hapus db.txt + pastikan `.env` ter-ignore**

```powershell
git rm db.txt
git check-ignore .env   # harus output .env
```

- [ ] **Step 6: Commit**

```powershell
git add -A
git commit -m "chore: hosting hardening htaccess user-ini, remove db.txt from repo"
```

---

### Task 14: Full test pass + smoke checklist + perbandingan visual

**Files:**
- Modify: bugfix apa pun yang ditemukan; `tests/` bila perlu test tambahan (Uploader happy-path dengan stub UploadedFile).

**Interfaces:**
- Consumes: seluruh aplikasi.

- [ ] **Step 1: Jalankan seluruh unit test**

```powershell
composer test
```
Expected: semua hijau. Jika gagal → perbaiki.

- [ ] **Step 2: Smoke checklist lengkap (browser atau curl)**

1. `GET /` → 200, tampil sesuai `portofolio.html` (nav, hero, marquee, work dari DB, about, services filter jalan, contact mailto, footer).
2. `page_views` bertambah per kunjungan; cookie `pv_id` terpasang.
3. Login `/admin/login` → dashboard menampilkan angka nyata.
4. CRUD project lengkap + cover upload (valid & terlalu besar → ditolak dengan pesan).
5. CRUD service; messages: tambah, toggle read (badge berkurang), delete.
6. Settings: ubah nama → langsung terlihat di `/`; upload portrait → tampil; live_site off → `/` = 503 maintenance; on lagi → normal.
7. Analytics `?days=7|30|90` render.
8. `GET /halaman-tidak-ada` → 404 styled. `POST` tanpa `_token` → 400 CSRF.
9. Throttle: 5× login salah → ditolak 15 menit.
10. Ganti password → logout → login password baru berhasil.

- [ ] **Step 3: Perbandingan visual**

Buka `http://localhost:8000/` vs file `portofolio.html` (via `php -S` di port lain: `php -S localhost:8001` di root — file statis). Bandingkan tiap section; perbaiki perbedaan mencolok (font, spacing, warna).

- [ ] **Step 4: Fix + commit final**

```powershell
git add -A
git commit -m "fix: smoke test findings and visual polish vs prototype"
```

- [ ] **Step 5: Laporan akhir ke user**

Ringkas: apa yang sudah jalan, cara menjalankan, kredensial default (`admin` / `ChangeMe_2026!` — sarankan ganti), langkah deploy (spec §12), file `full.sql` untuk phpMyAdmin, catatan password login sudah bisa diganti via Settings.
