# Design Spec — Website Portofolio + Admin Panel

Tanggal: 2026-10-10
Status: Disetujui (desain per bagian, 4/4)

## 1. Ringkasan

Membangun website portofolio publik + admin panel berbasis PHP dari dua prototipe HTML statis yang sudah ada:

- `portofolio.html` — halaman publik (Bahasa Inggris, single-page dengan anchor)
- `admin.html` — panel admin (Bahasa Indonesia, satu halaman berisi banyak section)

Hasil akhir: aplikasi PHP 8.2 + Slim 4 + MariaDB dengan konten yang dikelola lewat admin (CRUD), autentikasi, dan analitik pengunjung nyata. Markup visual mengikuti prototipe sedekat mungkin.

## 2. Keputusan & Jawaban Stakeholder

| Topik | Keputusan |
|---|---|
| Stack | PHP 8.2, Slim 4, MariaDB, template PHP biasa, Tailwind **CDN**, Alpine.js CDN, Lucide CDN |
| Database layer | PDO prepared statements + repository classes. Tanpa ORM. |
| Deployment | Shared hosting cPanel, **docroot tetap `public_html`** — semua file di root docroot, dilindungi `.htaccess` |
| Database | Pakai DB production remote (`163.223.227.38`, DB `gzcxepzd_portofolio`) juga untuk development. Migrasi **hanya additive** — tidak ada DROP/TRUNCATE/RESET. |
| Dev lokal | Install PHP 8.2 + Composer via winget; jalankan dengan `php -S localhost:8000 index.php` |
| Scope admin | CRUD Projects, Services, Messages; Login; Settings & content editing; Analytics real (visitor tracking) |
| Konten publik | **Tanpa form kontak** — tetap `mailto:`; Messages diisi manual dari admin |
| Fitur dibuang | AI Auto-Reply, Documentation, form kontak publik |
| Upload gambar | Ya — portrait, 2 polaroid About, cover proyek (semua dari admin) |
| Bahasa | Publik: Inggris (seperti prototipe). Admin: Indonesia (seperti prototipe) |

## 3. Goal / Non-Goal

**Goal**
- Publik: 1 halaman identik secara visual dengan `portofolio.html`, konten dari DB.
- Admin: login, kelola projects/services/messages/settings, lihat analitik nyata.
- Aman untuk shared hosting: tanpa build step di server, tanpa CLI yang wajib.

**Non-Goal**
- Multi-user / role, form kontak publik, email notification, AI auto-reply.
- SPA/JSON API, ORM, template engine pihak ketiga, dependency logging berat.
- Integration test terhadap DB produksi.

## 4. Struktur Proyek

Semua di root `public_html` (docroot tetap):

```
index.php              front controller (bootstrap Slim, dispatch)
.htaccess              rewrite ke index.php; deny src/, templates/, vendor/,
                       migrations/, storage/, .env; uploads/ tanpa eksekusi PHP
.env                   kredensial DB, APP_ENV, ADMIN_INITIAL_PASSWORD
.env.example           template tanpa nilai asli
composer.json           slim/slim, slim/psr7; dev: phpunit/phpunit
src/
  bootstrap.php        load .env, container, error handling, app->run()
  Container.php        PDO, repository, renderer, session
  Routes.php           definisi semua route
  Controllers/
    PublicController.php
    Admin/ AuthController, DashboardController, ProjectController,
           ServiceController, MessageController, AnalyticsController,
           SettingController
  Repositories/        ProjectRepository, ServiceRepository,
                       MessageRepository, SettingRepository,
                       ActivityRepository, PageViewRepository,
                       AdminRepository, LoginAttemptRepository
  Middleware/          AuthRequired, CsrfCheck, VisitorTracker
  Support/             View (render template), e(), Validator,
                       Uploader, Flash, Throttle
templates/
  public/              layout.php + partials: nav, hero, marquee,
                       work, about, services, contact, footer, maintenance
  admin/               layout.php + login, dashboard, projects/form,
                       services/form, messages, analytics, settings
uploads/               hasil upload (nama acak); .htaccess nonaktifkan PHP
storage/logs/          app.log (error_log)
migrations/            001_init.sql, full.sql (untuk import phpMyAdmin)
bin/                   migrate.php (CLI lokal), create-admin.php (CLI lokal)
tests/                 PHPUnit (unit, tanpa DB)
public assets: tidak perlu — Tailwind/Lucide/Alpine dari CDN
```

**Dev lokal:** `php -S localhost:8000 index.php` memakai front controller yang sama; `.htaccess` hanya berlaku di hosting.

## 5. Routing

### Publik (Bahasa Inggris)
| Method | Path | Keterangan |
|---|---|---|
| GET | `/` | Single-page; visitor tracking; draft project disembunyikan; jika `live_site=0` tampilkan halaman maintenance |

### Admin (Bahasa Indonesia, kecuali login dilindungi `AuthRequired`)
| Method | Path | Keterangan |
|---|---|---|
| GET/POST | `/admin/login` | Form login + throttle |
| POST | `/admin/logout` | Destroys session |
| GET | `/admin` | Dashboard |
| GET | `/admin/projects` | Tabel + pagination (10/halaman) |
| GET/POST | `/admin/projects/create` | Form tambah |
| GET/POST | `/admin/projects/{id}/edit` | Form ubah (ganti cover opsional) |
| POST | `/admin/projects/{id}/toggle` | Live ↔ Draft |
| POST | `/admin/projects/{id}/delete` | Hapus (+ file cover) |
| GET | `/admin/services` | Daftar + pagination |
| GET/POST | `/admin/services/create` | Form tambah |
| GET/POST | `/admin/services/{id}/edit` | Form ubah |
| POST | `/admin/services/{id}/delete` | Hapus |
| GET | `/admin/messages` | Daftar pesan (unread di atas) |
| POST | `/admin/messages/create` | Tambah pesan manual |
| POST | `/admin/messages/{id}/read` | Toggle dibaca/belum |
| POST | `/admin/messages/{id}/delete` | Hapus |
| GET | `/admin/analytics` | Chart + top paths, filter 7D/30D/90D |
| GET/POST | `/admin/settings` | Semua konten + upload + live toggle + ganti password |

Semua POST melewati middleware `CsrfCheck`. Setelah sukses: tulis `activities` → flash → redirect (PRG).

## 6. Skema Database

InnoDB, `utf8mb4_unicode_ci`. Tanpa prefix (DB khusus portofolio).

```sql
admins (
  id INT UNSIGNED AI PK,
  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
)

login_attempts (
  id INT UNSIGNED AI PK,
  ip VARCHAR(45) NOT NULL,
  attempted_at DATETIME NOT NULL,
  INDEX (ip, attempted_at)
)

projects (
  id INT UNSIGNED AI PK,
  title VARCHAR(160) NOT NULL,
  subtitle VARCHAR(200) NOT NULL DEFAULT '',
  year SMALLINT UNSIGNED NOT NULL,
  tech_stack JSON NOT NULL,            -- ["Next.js","AI"]
  status ENUM('live','draft') NOT NULL DEFAULT 'draft',
  description TEXT NOT NULL,
  highlights JSON NOT NULL,            -- ["bullet 1","bullet 2"]
  cover_image VARCHAR(190) NULL,       -- filename di uploads/
  icon VARCHAR(64) NULL,               -- nama lucide, fallback jika tanpa cover
  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 (status, sort_order)
)

services (
  id INT UNSIGNED AI PK,
  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,              -- ["fitur 1", ...]
  sort_order SMALLINT NOT NULL DEFAULT 0,
  created_at / updated_at seperti projects
)

messages (
  id INT UNSIGNED AI PK,
  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 (is_read, created_at)
)

settings (
  `key` VARCHAR(64) PK,
  value TEXT NULL
)

page_views (
  id BIGINT UNSIGNED AI PK,
  visitor_id CHAR(32) NOT NULL,        -- cookie 32 hex
  path VARCHAR(190) NOT NULL,
  referer VARCHAR(190) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX (created_at),
  INDEX (visitor_id)
)

activities (
  id INT UNSIGNED AI PK,
  title VARCHAR(190) NOT NULL,
  description VARCHAR(255) NOT NULL,
  url VARCHAR(190) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX (created_at)
)
```

**Seed:** `001_init.sql` menyisipkan 1 admin (username `admin`) dengan hash bcrypt dari password default `ChangeMe_2026!` (hash precomputed — supaya bisa di-import via phpMyAdmin tanpa CLI). README & halaman login menekankan: **segera ganti password via Settings**. `full.sql` = seluruh skema + seed dalam satu file untuk phpMyAdmin.

**Migrasi lokal:** `php bin/migrate.php` menjalankan file `migrations/*.sql` berurutan, mencatat yang sudah jalan. Di hosting: import `full.sql` via phpMyAdmin (sekali).

## 7. Settings (key → pemakaian)

| Key | Dipakai di |
|---|---|
| `owner_name`, `owner_role` | Brand admin, sapaan dashboard, footer |
| `available_badge` | Badge "Available for New Projects" (hero) |
| `hero_line1`, `hero_line2` (italic), `hero_line3` (gradient) | H1 hero — 3 segmen styling tetap di markup |
| `hero_subtitle` | Paragraf hero |
| `stat1_value/label`, `stat2_*`, `stat3_*` | 3 mini stats hero |
| `portrait` | Nama file portrait hero (upload) |
| `about_heading`, `about_p1`, `about_p2` | Section About (plain text, styling underline di markup) |
| `focus1_label/value` … `focus3_*` | 3 kartu Focus di About |
| `polaroid1`, `polaroid2` | 2 foto polaroid (upload); caption tetap statis |
| `contact_email` | Link mailto di section Contact & footer |
| `social_twitter`, `social_github`, `social_linkedin` | Nav, footer, kontak |
| `footer_text` | Teks copyright footer |
| `live_site` | `1` = publik tampil; `0` = halaman maintenance |

Semua value **plain text** — template menyisipkan ke markup berstyling, admin tidak bisa menyuntik HTML. Marquee tech stack & caption polaroid: **statis** (ikut prototipe, tidak diedit).

## 8. Detail Fitur

### Publik (`/`)
Render satu halaman berurutan: nav → hero → marquee → work → about → services → contact → footer.
- Work: query `status='live'` ORDER BY sort_order; tampilkan `cover_image` jika ada, jika tidak tampilkan kotak ikon `icon` (fallback `file-code`).
- Services: filter kategori (All/Development/Automation/Data) client-side dengan Alpine (`x-show`/`x-data`), isi dari DB.
- Jika `live_site=0` dan request bukan dari session admin login → maintenance page (badge + heading + subteks, mengikuti gaya prototipe).
- `VisitorTracker`: hanya `GET /` (bukan aset); set cookie `visitor_id` (32 hex, SameSite=Lax, 1 tahun) lalu INSERT 1 baris.

### Admin — Dashboard
- 4 stat card: Total Projects, Pesan belum dibaca, Pengunjung 30 hari, Page views 30 hari (tanpa badge persentase delta — cukup angka).
- Traffic chart: bar chart CSS seperti prototipe, data dari `page_views` (unik + total per hari, maks 90 hari; hari kosong = 0), filter 7D/30D/90D → GET param, bar puncak diberi warna rose.
- Activity feed: 8 terbaru dari `activities`.
- Recent projects (5) + Pesan terbaru (5) + toggle `live_site` (form mini POST).

### Admin — Projects
Tabel: judul+subtitle, tahun, chip tech, status (Live/Draft), aksi (edit/toggle/delete, delete pakai Alpine confirm). Form: title wajib, subtitle, year (1990–2100), tech_stack (input kategori dipisah koma → array), status select, description textarea wajib, highlights (textarea, 1 baris = 1 bullet, wajib ≥1), cover upload (opsional, jpg/jpeg/png/webp, maks 2 MB), icon (pilihan nama lucide umum dari daftar pendek: book-open, mic, bar-chart-2, monitor-smartphone, cpu, file-code, sun, arrow-up-right, mail). Urutan: `sort_order` (angka kecil = di atas).

### Admin — Services
Sama polanya: title, category (development/automation/data), icon, description, features (1/baris), sort_order.

### Admin — Messages
Daftar pesan (belum dibaca di atas, badge "Baru"), form tambah manual (nama, email, body), toggle read/unread, delete. **Tidak ada form kontak publik.**

### Admin — Analytics
Chart sama dengan dashboard (parameter 7/30/90) + tabel "Top Paths" (path, views, unik) + ringkasan 30 hari.

### Admin — Settings
Form tunggal (multipart): profil (owner_name, owner_role), badge, 3 baris hero, subtitle, 3 stats, heading/paragraf about, 3 focus cards, contact_email, 3 social link, footer_text, upload portrait/polaroid1/polaroid2 (file lama dihapus saat diganti), toggle live_site, dan **ganti password** (password saat ini + password baru ×2, verifikasi `password_hash` lama).

## 9. Keamanan

- **XSS:** output selalu `e()`; settings plain text.
- **SQLi:** prepared statements di semua query.
- **CSRF:** token session di setiap form POST; middleware `CsrfCheck` menolak 400 jika tidak cocok.
- **Auth:** `password_hash()`/`password_verify()` (bcrypt); `session_regenerate_id(true)` setelah login; cookie `httpOnly + SameSite=Lax` (+`Secure` jika HTTPS).
- **Throttle:** ≥5 `login_attempts` per IP dalam 15 menit → tolak dengan pesan; reset saat berhasil.
- **Upload:** ekstensi whitelist jpg/jpeg/png/webp + MIME via `finfo` + `getimagesize`; maks 2 MB; nama acak `bin2hex(random_bytes(16)).ext`; file lama dihapus saat replace/delete; `uploads/.htaccess` menonaktifkan PHP.
- **.htaccess:** deny akses langsung ke `src/`, `templates/`, `vendor/`, `migrations/`, `storage/`, `bin/`, `.env`, `composer.*`.
- **.env** hanya di root; `.env.example` tanpa nilai asli. `db.txt` (berisi password produksi) harus dihapus/di-rewrite setelah bermigrasi ke `.env`.

## 10. Error Handling

- `APP_ENV` di `.env`: `development` | `production`.
- 500: production → halaman 500 bergaya prototipe (tanpa detail) + log ke `storage/logs/app.log` (timestamp, pesan, file:baris) via `error_log()`; development → tampil exception.
- 404: gaya publik untuk rute publik, gaya admin untuk `/admin/*`.
- `.env` belum terisi / koneksi DB gagal: pesan jelas di development, generik di production.
- Validasi gagal: render ulang form + error per-field + old input; upload gagal: flash merah.
- Flash message sekali tampil lalu hilang (session).

## 11. Testing

1. **PHPUnit (unit, tanpa DB):** `e()`, encode/decode JSON kolom, Validator (projects/services/messages/settings), Throttle login, mapping settings default.
2. **Smoke test manual** via `php -S localhost:8000 index.php` (checklist): login → CRUD project + upload cover → CRUD service → tambah/read/delete message → toggle live_site & cek maintenance → kunjungi `/` & verifikasi baris `page_views` masuk → chart 7D/30D/90D → 404 → ganti password → logout.
3. **Tanpa** integration test ke DB produksi.
4. Verifikasi akhir: `composer test` + perbandingan visual kedua halaman terhadap prototipe.

## 12. Deployment (shared hosting)

1. Buat DB + user di cPanel, atau pakai yang sudah ada (dari `db.txt`).
2. Upload semua file kecuali `.env` development; salin `.env.example` → `.env`, isi kredensial `APP_ENV=production`.
3. Import `migrations/full.sql` via phpMyAdmin (sekali; bersifat additive/idempoten).
4. Login `/admin/login` (admin / `ChangeMe_2026!`), **langsung ganti password**.
5. Isi Settings (foto, teks, sosial) → set `live_site` = 1.
6. `.user.ini` disertakan (upload_max_filesize=2M, post_max_size=3M) jika hosting mengizinkan.
7. Hapus `db.txt`, `admin.html`, `portofolio.html` dari docroot setelah verifikasi (atau pindahkan ke luar public_html).

## 13. Urutan Implementasi (untuk implementation plan)

1. Setup lokal: winget install PHP 8.2 + Composer; `composer create-project` struktur; pasti `php -S` jalan.
2. Migrasi + seed (001_init.sql, full.sql, bin/migrate.php).
3. Bootstrap app: .env loader, container, PDO, View, e(), flash, CSRF, error handling.
4. Auth (login/logout/throttle/AuthRequired).
5. Repositori + halaman admin CRUD: Projects (dengan upload) → Services → Messages.
6. Settings (semua key + upload + ganti password + live toggle).
7. Halaman publik dari prototipe + integrasi data + VisitorTracker.
8. Analytics (query agregat + chart + halaman analytics).
9. Dashboard (stat, chart, activity, recent, toggle).
10. Polish: maintenance page, 404, logging, .htaccess, .user.ini, .env.example.
11. PHPUnit + smoke test + perbandingan visual vs prototipe.
