# Content Media — Media Library (Pustaka Media)

Modul admin untuk seksyen **Pustaka Media / Media Library** portal: **Keratan Akhbar** dan **Kenyataan Media & Siaran Media** (+ Infografik). Struktur **sama seperti `content-application`**: parent table + translation table berasingan (bukan self-referencing).

> Portal asal: `nres.gov.my/ms-my/pustakamedia/…` (BM = *Pustaka Media*) · `nres.gov.my/en-my/medialibrary/…` (EN = *Media Library*).
> Modul ini dahulunya bernama `content-clipping` (Keratan Akhbar sahaja) dan dinamakan semula kerana portal mengurus Keratan Akhbar + Kenyataan/Siaran Media dalam satu seksyen yang sama.

## 1. Table

**`content_media`** (PK `media_id`)

| Kolum | Jenis | Nota |
|---|---|---|
`media_type` | varchar(50) null | **wajib** — `ref` `MEDIA_TYPE` (`KERATAN_AKHBAR`, `KENYATAAN_MEDIA`, `SIARAN_MEDIA`, `INFOGRAFIK`) |
`media_source` | varchar(100) null | `ref` `MEDIA_SOURCE` — **wajib hanya untuk `KERATAN_AKHBAR`** |
`media_date` | date null | **wajib** — asas tapisan/tab tahun |
`media_url` | varchar(500) null | pautan artikel asal (biasanya keratan) |
`media_img` | varchar(255) null | thumbnail / imej infografik |
`media_file` | varchar(255) null | PDF/imbasan — opsional (semua jenis) |
`media_status` | varchar(20) null | `ref` `MEDIA_STATUS` (`PUBLISHED`, `DRAFT`, `ARCHIVED`) |
`media_sort` | int default 0 | susunan dalam kumpulan Jenis (+ Sumber/Tahun bila ditapis) |
`media_sector` | varchar(100) null | **disediakan** untuk fasa sekatan akses sektor (belum ada UI/guard) |
`created_by`, `updated_by`, `created_at`, `updated_at` | | |

Index: `media_type`, `media_source`, `media_date`, `media_sector`.

**`content_media_translations`** (PK `media_translation_id`)

| Kolum | Jenis | Nota |
|---|---|---|
`media_translation_parent_id` | int null | FK → `media_id` |
`media_translation_title` | varchar(255) null | tajuk (main-language wajib) |
`media_translation_summary` | text null | ringkasan |
`media_translation_main` | int null | `1` = bahasa utama |
`media_translation_language` | varchar(255) null | kod bahasa (`ref` `LANGUAGE`) |
`created_by`, `updated_by`, `created_at`, `updated_at` | | |

Index: `media_translation_parent_id` (nama `media_translations_parent_index` — had 64 aksara MySQL).

## 2. Fail

| Jenis | Path |
|---|---|
Model | `app/Models/Backend/ContentMedia.php`, `ContentMediaTranslation.php` |
Request | `app/Http/Requests/Backend/ContentMediaRequest.php` |
Controller | `app/Http/Controllers/Backend/ContentMediaController.php` |
Views | `resources/views/backend/module/contentMedia/{index,create,edit,_form}.blade.php` + `partials/{_select,_date-input}.blade.php` |
Storan | `storage/app/public/content-media/{images,files}` |
Migration | `database/migrations/2026_10_22_110001_create_content_media_table.php`, `…_110002_create_content_media_translations_table.php` |
Test | `tests/Feature/ContentMediaTest.php` |
Menu/Permission | `database/seeders/BackendMenuSeeder.php` (**Content → Media Library**) |
Ref | `database/seeders/RefDataSeeder.php` (`MEDIA_TYPE`, `MEDIA_SOURCE`, `MEDIA_STATUS`) |

## 3. Routes

| Method | URI | Permission | Name |
|---|---|---|---|
GET | `/content-media` | `content-media.view` | `content-media.index` |
GET | `/content-media/create` | `.create` | `content-media.create` |
POST | `/content-media` | `.create` | `content-media.store` |
GET | `/content-media/{id}/edit` | `.update` | `content-media.edit` |
PUT | `/content-media/{id}` | `.update` | `content-media.update` |
DELETE | `/content-media/{id}` | `.delete` | `content-media.destroy` |
POST | `/content-media/reorder` | `.update` | `content-media.reorder` |

Menu admin: **Content → Media Library** + `backend_role_mapping` (super-admin).

## 4. Senarai (index)

Kolum: **#** (grip) · **Title** (thumbnail 48×48 + kiraan bahasa + pautan *Article Link*/*Scan*) · **Type** (badge kelabu) · **Source** (badge biru) · **Date** · **Status** (badge) · **Sort** · **Actions**

Penapis: **Type · Source · Year · Search** (auto-submit; Search guna debounce 1.5s) + butang **Reset** (`_index-reset-button`).

### Drag & drop

Key reorder = **Type** (+ Source/Tahun bila ditapis). `POST content-media.reorder` (`ids`, `type`, `source`, `year`, `page`, `perPage`) — kemas `media_sort` untuk baris dalam penapis yang sama sahaja; `type` **wajib** (422 jika tiada).

| Keadaan | Ikon sel `#` | Notis |
|---|---|---|
Tiada penapis **Type** | grip **merah** | "Use the **Type** (and **Source**) filter above to narrow the list, then drag & drop to reorder." |
Ada penapis Type + **1** rekod | grip **kuning** | "Add at least **2 items** in this filter to enable drag & drop reordering." |
Ada penapis Type + **≥2** rekod | grip **hijau** (`.drag-handle`) | — |

## 5. Borang (create/edit)

1. Kad **Translations** — tab bahasa (Alpine), *Main Translation*, Title (wajib untuk bahasa utama) + Summary
2. Kad **Media Information**:
   - **Baris 1** (baris fleksibel `.media-info-row`): **Type** (wajib) · **Source** · **Status** (wajib)
     - **Source mengikut Type**: pilihan diambil dari `ref MEDIA_SOURCE` yang `parent` = kod jenis tersebut (lihat §6). Medan dipaparkan **hanya jika jenis itu ada senarai sumber**; JS dalam `_form.blade.php` membina semula pilihan bila Type bertukar (nilai dikosongkan jika tidak sah untuk jenis baharu).
     - Tanda `*` pada label **Source** ialah penanda dinamik (`data-required-mark`) yang dipaparkan bila jenis itu mewajibkan sumber. **Tiada atribut HTML `required`** — pengesahan dibuat **di server** (`ContentMediaRequest`).
     - Baris ini guna `flex-wrap` + CSS `.media-info-row > * { flex: 1 1 180px }` (1 medan/baris pada skrin kecil) supaya medan **berkongsi ruang sama rata** sama ada Source dipapar (3 medan) atau disembunyikan (2 medan) — tiada ruang kosong tinggal.
   - **Baris 2** (grid 3 kolum): **Publish Date** (wajib) · **Sort** · **Article Link**
   - **Baris 3**: **Cover Image** (FilePond, opsional)
   - **Baris 4**: **Scan File** (FilePond, opsional)
3. **Save** / **Update** → redirect ke index; padam guna SweetAlert (translation + fail turut dipadam)

**Validation bersyarat** (`ContentMediaRequest`):

- `media_type` wajib
- `media_source` **wajib hanya untuk `KERATAN_AKHBAR`**; jika diisi untuk jenis lain, ia mesti tergolong dalam senarai jenis tersebut
- `media_url` mesti URL sah (`http://` / `https://`) · `media_date` wajib
- **Semua fail opsional** — `media_img` (JPG/PNG/WebP, max 15MB) · `media_file` (PDF/JPG/PNG, max 15MB)

## 6. Ref

| Cat | Kod |
|---|---|
`MEDIA_TYPE` | `KERATAN_AKHBAR`, `KENYATAAN_MEDIA`, `SIARAN_MEDIA`, `INFOGRAFIK` |
`MEDIA_SOURCE` | **Akhbar** (`parent = KERATAN_AKHBAR`): `BH`, `NST`, `THE_STAR`, `SINAR_HARIAN`, `UTUSAN`, `BERNAMA`, `OTHERS` · **Agensi** (`parent = KENYATAAN_MEDIA`): `NRES`, `JMG`, `FRIM`, `JUPEM`, `PERHILITAN`, `JKPTG`, `INSTUN` |
`MEDIA_STATUS` | `PUBLISHED`, `DRAFT`, `ARCHIVED` |

Label dropdown guna `descr_en` fallback `descr`.

> **Sumber ikut jenis media:** kolum `ref.parent` pada baris `MEDIA_SOURCE` menyimpan **kod `MEDIA_TYPE`**. `SIARAN_MEDIA` & `INFOGRAFIK` berkongsi senarai `KENYATAAN_MEDIA` (lihat `ContentMedia::SOURCE_TYPE_FALLBACK`); baris tanpa `parent` dikongsi semua jenis. Resolusi: `ContentMedia::sourceGroups()` (semua kumpulan) & `ContentMedia::sourcesForType($type)` (satu jenis). `ContentMediaRequest` mewajibkan sumber **hanya untuk `KERATAN_AKHBAR`** dan menolak sumber yang tidak tergolong dalam senarai jenis itu; `ContentMediaController::normalizeSource()` set `null` jika jenis tiada senarai.

## 7. Skop portal & modul lain

| Halaman portal | Modul |
|---|---|
Keratan Akhbar Semasa + Arkib Keratan Akhbar | **`content-media`** (`KERATAN_AKHBAR`) |
Kenyataan Media & Siaran Media + Arkib + Infografik | **`content-media`** (`KENYATAAN_MEDIA`, `SIARAN_MEDIA`, `INFOGRAFIK`) |
Teks Ucapan / Koleksi Ucapan | `content-speech` |
Berita + Arkib Berita | `content-article` (cat `NEWS`) |
Penerbitan Kementerian/Jabatan · Polisi/Dasar · Buletin · Laporan | `content-download` |
Koleksi Video | `content-video` |
Pautan Akhbar (20 pautan luar) | belum dibuat (modul kecil) |

**Nota bukti portal:** halaman arkib portal (`ArkibKenyataanMedia.aspx`, `KeratanAkhbar.aspx`) menggunakan **tab tahun** (`2026 … 2020 · Lain-lain`) dan setiap item hanya **tajuk + PDF**. Sub-jenis kenyataan pada portal diselit dalam tajuk (`KENYATAAN MEDIA …`, `SIARAN MEDIA …`, `PRESS RELEASE …`) — modul ini memformalkannya melalui `media_type`.

## 8. Ujian

`tests/Feature/ContentMediaTest.php` — **17 ujian**:

- index & create render
- validation: `media_type` wajib · **sumber wajib hanya untuk Keratan Akhbar** · sumber yang tidak tergolong dalam senarai jenis itu ditolak · URL tidak sah ditolak · **fail (scan) opsional untuk semua jenis**
- store: simpan fail (`content-media/files`, `content-media/images`) + 2 translation (main `en`)
- kenyataan media **tanpa sumber & tanpa fail → lulus** · dengan sumber agensi (`JMG`) + PDF → lulus
- update: ganti translation, fail lama kekal bila tidak diganti
- destroy: translation + fail terpadam
- tapisan **Type / Source / Year**
- notis & ikon reorder (merah / kuning / hijau)
- reorder: kemas `media_sort` dalam Type sahaja · `type` wajib (422)
- sumber ikut jenis: `sourceGroups()` (Siaran Media & Infografik ikut senarai Kenyataan Media) + borang memuat `sourceGroups`
- penanda `*` pada label Source dinamik (`data-required-mark`) dan **tiada** `setAttribute('required')` dalam borang (pengesahan sepenuhnya di server)

## 9. Fasa seterusnya (belum dibuat)

1. **Frontend Pustaka Media** — **sebahagian siap**: halaman *Kenyataan Media & Siaran Media* sudah dibina (lihat §10). Belum: `keratan-akhbar-web`, `arkib-keratan-akhbar-web`, `arkib-kenyataan-media-web`, `infografik-web` (tab tahun dari data + *Lain-lain* untuk tarikh kosong; butang *Baca PDF* guna document reader sedia ada).
2. **Sekatan akses sektor** — isi `media_sector` + guard peringkat baris (trait `SectorScoped`) supaya setiap sektor hanya urus kandungan sendiri.
3. **Pautan Akhbar** — modul kecil untuk 20 pautan luar (15 domestik + 7 antarabangsa).
4. **Infografik** — UI/page khusus (grid imej) bagi `media_type = INFOGRAFIK` (nilai ref sudah ada).

## 10. Halaman frontend — Kenyataan Media & Siaran Media

Halaman awam **`/nres/media-statement`** (page ID **34**, `frontend_pages.page_content`, slug `media-statement`).
Konsep diambil daripada portal NRES `PustakaMedia/Pages/KenyataanMedia.aspx` — senarai jadual **Tarikh | Nama/Dokumen**
(tab jenis + penapis sumber/tahun ditambah mengikut struktur `content_media`).

**Sumber data** — hanya `media_status = PUBLISHED`:

| Jadual | Medan digunakan |
|---|---|
| `content_media` | `media_type`, `media_source`, `media_date`, `media_url`, `media_img`, `media_file` |
| `content_media_translations` | `media_translation_title`, `media_translation_summary` (BM/EN ikut `session('locale')`, jatuh balik ke `media_translation_main = 1`) |

**Struktur halaman**

1. Breadcrumb · tajuk · lead (BM/EN)
2. **Tab jenis** — `Semua` (lalai) · `Kenyataan Media` · `Siaran Media`; label dari ref `MEDIA_TYPE` (`descr_en` untuk EN). Senarai jenis ada dalam `$medTypes` → tambah `KERATAN_AKHBAR` / `INFOGRAFIK` bila perlu.
3. **Penapis chip** (dipaparkan hanya jika ada >1 pilihan): **Sumber** (ref `MEDIA_SOURCE`) · **Tahun** (dari `media_date`), setiap chip ada kiraan.
4. **Jadual** — `#` · `Tarikh` (`dd/mm/yyyy`) · `Tajuk` (**tajuk sahaja**, pautan ke butiran) · `Jenis` (lencana `MEDIA_TYPE`, hanya apabila tab `Semua` aktif) · `Sumber` · `Dokumen` — **satu tindakan sahaja**: **Muat turun** (`media_file`) · **Buka pautan** (`media_url` sahaja) · `—` (tiada kedua-duanya). Tiada butang "Baca"; ringkasan, imej & nama sumber penuh hanya di paparan butiran (masuk melalui pautan tajuk).
5. **Butiran** (`?id=`) — lencana, tajuk, tarikh, sumber, imej, ringkasan penuh, muat turun dokumen, pautan luar, kembali ke senarai. Rekod tanpa ringkasan → nota "Sila rujuk dokumen yang dilampirkan".
6. Nota pertanyaan media (teks biasa — tiada pautan `mailto:`/`tel:`).

**Parameter URL**

| Param | Nilai | Lalai |
|---|---|---|
| `jenis` | `KENYATAAN_MEDIA`, `SIARAN_MEDIA` | semua jenis |
| `sumber` | kod `MEDIA_SOURCE` (cth `NRES`, `JMG`, `FRIM`) | semua sumber |
| `tahun` | tahun `media_date` (cth `2026`) | semua tahun |
| `id` | `media_id` | papar senarai |

Nilai tidak sah (jenis/sumber/tahun tiada dalam data, `id` tidak wujud) → **jatuh balik ke lalai** tanpa ralat.

**Nota teknikal**

- Seluruh kod (logik + markup + CSS) berada dalam `frontend_pages.page_content` — **tiada komponen/partial**. Cermin Blade disegerakkan oleh `BladeSyncService::syncPageContent()` → `PAGE_CONTENT/nres/media-statement.blade.php`.
- Nama sumber panjang diringkaskan untuk chip/jadual (`$medSourceShort`: label > 24 aksara → guna kod, cth `NRES`); nama penuh kekal dalam atribut `title` dan paparan butiran.
- Warna guna CSS variable tema (`--teal`, `--green`, `--fade`, `--line`, `--ink`) → sokong mod gelap.
- Fail: `media_file` / `media_img` pada disk `public` → `asset('storage/'.$path)`; laluan bermula `dynaweb4/` → `asset($path)`.
- Jadual runtuh kepada kad bertindan pada ≤680px (disemak: tiada limpihan mendatar pada 420px).

**Data contoh** — `storage/app/cms-page-content/seed-media-sample.php`: 9 rekod (6 kenyataan + 3 siaran, 2025–2026, BM + EN, PDF + imej contoh) di `content-media/files/contoh` & `content-media/images/contoh`; `php seed-media-sample.php --delete` untuk padam.

**Ujian** — `storage/app/cms-page-content/verify-media.php` (10 semakan: senarai, tab jenis, penapis sumber/tahun, butiran, nilai tidak sah, gabungan penapis, fail PDF & imej) + `check-media-render.php` (markup baris, kes tepi rekod tanpa ringkasan, tukar bahasa BM/EN via `/lang/{code}`).

