Sabtu, 11 April 2026

MPA-News Portal

 


PROMPT

Prompt Master: Pembangunan Portal Berita Modern (Google Apps Script)

Deskripsi Proyek

Buatlah sebuah aplikasi Portal Berita lengkap menggunakan ekosistem Google (Google Apps Script, Sheets, dan Drive) dengan tampilan modern, minimalis, dan responsif. Aplikasi ini harus memiliki sistem Multi-Page Application (MPA) sederhana yang memisahkan halaman pembaca dan halaman admin.

Arsitektur Teknis

  1. Backend (Google Apps Script - Code.gs):

    • Gunakan doGet(e) untuk routing halaman menggunakan parameter URL ?page=admin.

    • Implementasikan fungsi getLatestNews() untuk mengambil data dari Google Sheets.

    • Implementasikan fungsi prosesInputBerita(data) untuk menangani upload gambar Base64 ke Google Drive dan menyimpan metadata ke Google Sheets.

    • Sertakan fungsi setupDatabase() untuk mengotomatisasi pembuatan folder Drive dan spreadsheet dengan header: Timestamp, Judul, Kategori, Isi, UrlFoto.

  2. Database (Google Sheets):

    • Nama Sheet: "Berita".

    • Kolom: [A] Timestamp, [B] Judul, [C] Kategori, [D] Isi, [E] UrlFoto.

  3. Frontend (HTML/Tailwind CSS):

    • Halaman Utama (index.html):

      • Desain modern menggunakan font Playfair Display (Serif) untuk judul dan Inter (Sans) untuk isi.

      • Hero Section: Menampilkan berita terbaru dengan gambar besar, gradient overlay, dan kategori.

      • Trending Sidebar: Daftar berita populer dengan penomoran besar.

      • News Grid: Layout kartu responsif untuk berita lainnya.

      • Fitur Modal: Saat berita diklik, munculkan modal backdrop-blur yang menampilkan konten lengkap tanpa berpindah halaman.

      • Fallback System: Gunakan data dummy jika objek google.script tidak terdeteksi (untuk lingkungan pratinjau).

      • Image Formatter: Fungsi untuk mengubah URL Drive menjadi direct link lh3.googleusercontent.com/d/[ID].

    • Panel Admin (Admin.html):

      • Form input berita yang elegan dengan area Drag-and-Drop untuk upload foto.

      • Preview gambar sebelum diunggah.

      • Notifikasi sukses/gagal yang muncul setelah proses google.script.run selesai.

      • Loading overlay saat proses upload sedang berlangsung.

Fitur Utama

  • Upload Gambar: Simpan foto langsung ke Drive dan simpan URL publiknya di Sheets.

  • Responsif: Tampilan harus menyesuaikan secara sempurna di perangkat mobile, tablet, dan desktop.

  • Interaktif: Animasi fade-in, efek hover pada kartu berita, dan transisi halus pada modal.

  • Navigasi: Link dinamis menggunakan <?= getAppUrl() ?> untuk berpindah antar halaman.

Petunjuk Eksekusi

  • Jalankan setup otomatis untuk mendapatkan ID aset.

  • Deploy sebagai Web App dengan akses "Anyone".

  • Pastikan izin akses Drive disetel ke "Anyone with the link can view".


Spesifikasi FrontEnd

Prompt Master: Pembangunan Portal Berita Modern (Google Apps Script)

Deskripsi Proyek

Buatlah sebuah aplikasi Portal Berita lengkap menggunakan ekosistem Google (Google Apps Script, Sheets, dan Drive) dengan tampilan modern, minimalis, dan responsif. Aplikasi ini harus memiliki sistem Multi-Page Application (MPA) sederhana yang memisahkan halaman pembaca dan halaman admin.

Arsitektur Teknis

  1. Backend (Google Apps Script - Code.gs):

    • Gunakan doGet(e) untuk routing halaman menggunakan parameter URL ?page=admin.

    • Implementasikan fungsi getLatestNews() untuk mengambil data dari Google Sheets.

    • Implementasikan fungsi prosesInputBerita(data) untuk menangani upload gambar Base64 ke Google Drive dan menyimpan metadata ke Google Sheets.

    • Sertakan fungsi setupDatabase() untuk mengotomatisasi pembuatan folder Drive dan spreadsheet dengan header: Timestamp, Judul, Kategori, Isi, UrlFoto.

  2. Database (Google Sheets):

    • Nama Sheet: "Berita".

    • Kolom: [A] Timestamp, [B] Judul, [C] Kategori, [D] Isi, [E] UrlFoto.

  3. Spesifikasi Detil Frontend (HTML/Tailwind CSS):

    Desain Sistem & Tipografi

    • Tipografi: Gunakan font Playfair Display (Serif) untuk memberikan kesan koran profesional pada judul berita (heading), dan Inter (Sans-serif) untuk keterbacaan tinggi pada teks isi (body).

    • Palet Warna: * Primary: Dark Slate (#0f172a) untuk elemen navigasi dan teks utama.

      • Accent: Sky Blue (#0ea5e9) untuk kategori, tombol, dan elemen interaktif.

      • Neutral: Gray scales untuk background (#f8fafc) dan teks sekunder.

    Komponen Antarmuka (index.html)

    • Navbar: Desain sticky dengan efek blur (glassmorphism), logo teks di kiri, dan link kategori di tengah/kanan.

    • Hero Section (Utama): * Grid 12 kolom (Desktop).

      • Area utama (8 kolom) menampilkan satu berita terbaru dengan gambar resolusi tinggi, gradient overlay gelap di bawah teks, dan tag kategori.

      • Area trending (4 kolom) menampilkan daftar 3-4 berita populer berikutnya dengan tipografi nomor besar (bold).

    • News Feed (Grid): Layout kartu responsif (1 kolom mobile, 2 tablet, 3 desktop) yang menampilkan thumbnail berita dengan aspek rasio 16:10, judul tebal, dan cuplikan isi berita (excerpt).

    • Modal Reader: * Implementasikan modal pop-up yang muncul saat kartu berita diklik.

      • Sertakan tombol tutup (X), gambar utama di atas, tanggal publikasi, dan teks isi berita lengkap dengan format paragraf yang rapi (whitespace-pre-line).

    • Sistem Loading: Tampilkan elemen Skeleton Loader (animasi pulse gray) selama data diambil dari server untuk meningkatkan User Experience.

    Panel Manajemen (Admin.html)

    • Dashboard Layout: Fokus pada form input di tengah dengan lebar maksimal (max-w-6xl).

    • Form Input: * Input judul tanpa border (borderless) dengan ukuran font besar.

      • Area drag-and-drop untuk unggah foto dengan indikator visual saat file ditarik masuk.

      • Fitur Image Preview instan menggunakan FileReader API sebelum data dikirim.

    • Status Indikator: Tombol "Terbitkan" yang berubah menjadi state loading saat proses simpan berlangsung, diikuti dengan notifikasi toast atau kotak pesan sukses/gagal.

    Logika Frontend & Interaksi

    • Image Formatter: Logika JavaScript untuk mengubah ID file Drive atau URL Drive mentah menjadi format link stabil https://lh3.googleusercontent.com/d/[FILE_ID] agar gambar dapat dimuat langsung di tag <img>.

    • Animasi: Gunakan transisi halus pada hover kartu (scale-105), durasi transisi minimal 300ms, dan efek fade-in pada elemen saat data selesai dimuat.

    • Error Handling: Tampilkan pesan bantuan yang ramah jika database kosong atau terjadi kegagalan koneksi server.

Fitur Utama

  • Upload Gambar: Simpan foto langsung ke Drive dan simpan URL publiknya di Sheets secara otomatis.

  • Responsif: Tampilan harus menyesuaikan secara sempurna di perangkat mobile, tablet, dan desktop (Mobile-First approach).

  • Navigasi: Gunakan link dinamis menggunakan scriptlet GAS <?= getAppUrl() ?> untuk mengelola perpindahan antar halaman tanpa hard-coded URL.

Petunjuk Eksekusi

  • Jalankan fungsi setupDatabase() satu kali untuk mendapatkan ID aset dan mengatur header kolom.

  • Deploy aplikasi sebagai Web App dengan pengaturan akses "Anyone" agar dapat diakses oleh pembaca umum.

  • Pastikan pengaturan berbagi (sharing) pada Folder Google Drive disetel ke "Anyone with the link can view" agar gambar dapat tampil di frontend.


Spesifikasi BackEnd

Prompt Master: Pembangunan Portal Berita Modern (Google Apps Script)

Deskripsi Proyek

Buatlah sebuah aplikasi Portal Berita lengkap menggunakan ekosistem Google (Google Apps Script, Sheets, dan Drive) dengan tampilan modern, minimalis, dan responsif. Aplikasi ini harus memiliki sistem Multi-Page Application (MPA) sederhana yang memisahkan halaman pembaca dan halaman admin.

Arsitektur Teknis

  1. Backend (Google Apps Script - Code.gs):

    • Gunakan doGet(e) untuk routing halaman menggunakan parameter URL ?page=admin.

    • Implementasikan fungsi getLatestNews() untuk mengambil data dari Google Sheets.

    • Implementasikan fungsi prosesInputBerita(data) untuk menangani upload gambar Base64 ke Google Drive dan menyimpan metadata ke Google Sheets.

    • Sertakan fungsi setupDatabase() untuk mengotomatisasi pembuatan folder Drive dan spreadsheet dengan header: Timestamp, Judul, Kategori, Isi, UrlFoto.

  2. Database (Google Sheets):

    • Nama Sheet: "Berita".

    • Kolom: [A] Timestamp, [B] Judul, [C] Kategori, [D] Isi, [E] UrlFoto.

  3. Spesifikasi Detil Backend (Google Apps Script):

    Manajemen Routing & Akses

    • doGet(e) Entry Point: Fungsi utama untuk menangani permintaan GET. Gunakan HtmlService.createTemplateFromFile untuk merender index.html sebagai default dan Admin.html jika parameter page=admin diberikan.

    • URL Helper: Buat fungsi getAppUrl() yang mengembalikan ScriptApp.getService().getUrl() agar navigasi antar halaman tetap dinamis meskipun URL deployment berubah.

    • Environment Mode: Tambahkan setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) pada objek HtmlOutput agar aplikasi dapat berjalan lancar di dalam iframe atau lingkungan pratinjau.

    Pengolahan Data (getLatestNews)

    • Data Sanitization: Lakukan filter terhadap baris kosong di Spreadsheet untuk mencegah error "undefined" pada frontend.

    • Date Normalization: Pastikan kolom Timestamp dikonversi menjadi format string ISO (toISOString()) agar objek Date dari Google Sheets dapat diproses dengan benar oleh JavaScript di sisi klien.

    • Sorting: Urutkan data secara terbalik (LIFO - Last In First Out) sehingga berita terbaru selalu berada di urutan pertama array.

    Sistem Unggah & File (prosesInputBerita)

    • Base64 Processing: Terima data foto dalam format Base64. Gunakan Utilities.base64Decode untuk mengubah string tersebut menjadi byte array, lalu buat objek Blob dengan MIME type yang sesuai (image/jpeg, image/png, dll).

    • Drive Integration: Simpan file gambar ke folder tertentu menggunakan DriveApp.getFolderById(). Berikan izin akses publik pada file secara otomatis menggunakan .setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW).

    • URL Generation: Hasilkan URL langsung menggunakan pola https://drive.google.com/uc?export=view&id=[FILE_ID] atau simpan ID-nya saja untuk diproses oleh utilitas frontend.

    Integritas Database (setupDatabase)

    • Automasi Aset: Fungsi ini harus memeriksa apakah file Spreadsheet dan Folder Drive sudah ada. Jika belum, buat secara otomatis menggunakan SpreadsheetApp.create() dan DriveApp.createFolder().

    • Header Protection: Atur baris pertama Spreadsheet dengan header yang tebal (bold), latar belakang berwarna, dan pembekuan baris (freeze) untuk memudahkan pengelolaan manual.

    • Logging: Gunakan Logger.log() untuk menampilkan ID Spreadsheet dan ID Folder yang dihasilkan agar user bisa menyalinnya ke variabel konstanta di bagian atas skrip.

  4. Spesifikasi Detil Frontend (HTML/Tailwind CSS):

    Desain Sistem & Tipografi

    • Tipografi: Gunakan font Playfair Display (Serif) untuk memberikan kesan koran profesional pada judul berita (heading), dan Inter (Sans-serif) untuk keterbacaan tinggi pada teks isi (body).

    • Palet Warna: * Primary: Dark Slate (#0f172a) untuk elemen navigasi dan teks utama.

      • Accent: Sky Blue (#0ea5e9) untuk kategori, tombol, dan elemen interaktif.

      • Neutral: Gray scales untuk background (#f8fafc) dan teks sekunder.

    Komponen Antarmuka (index.html)

    • Navbar: Desain sticky dengan efek blur (glassmorphism), logo teks di kiri, dan link kategori di tengah/kanan.

    • Hero Section (Utama): * Grid 12 kolom (Desktop).

      • Area utama (8 kolom) menampilkan satu berita terbaru dengan gambar resolusi tinggi, gradient overlay gelap di bawah teks, dan tag kategori.

      • Area trending (4 kolom) menampilkan daftar 3-4 berita populer berikutnya dengan tipografi nomor besar (bold).

    • News Feed (Grid): Layout kartu responsif (1 kolom mobile, 2 tablet, 3 desktop) yang menampilkan thumbnail berita dengan aspek rasio 16:10, judul tebal, dan cuplikan isi berita (excerpt).

    • Modal Reader: * Implementasikan modal pop-up yang muncul saat kartu berita diklik.

      • Sertakan tombol tutup (X), gambar utama di atas, tanggal publikasi, dan teks isi berita lengkap dengan format paragraf yang rapi (whitespace-pre-line).

    • Sistem Loading: Tampilkan elemen Skeleton Loader (animasi pulse gray) selama data diambil dari server untuk meningkatkan User Experience.

    Panel Manajemen (Admin.html)

    • Dashboard Layout: Fokus pada form input di tengah dengan lebar maksimal (max-w-6xl).

    • Form Input: * Input judul tanpa border (borderless) dengan ukuran font besar.

      • Area drag-and-drop untuk unggah foto dengan indikator visual saat file ditarik masuk.

      • Fitur Image Preview instan menggunakan FileReader API sebelum data dikirim.

    • Status Indikator: Tombol "Terbitkan" yang berubah menjadi state loading saat proses simpan berlangsung, diikuti dengan notifikasi toast atau kotak pesan sukses/gagal.

    Logika Frontend & Interaksi

    • Image Formatter: Logika JavaScript untuk mengubah ID file Drive atau URL Drive mentah menjadi format link stabil https://lh3.googleusercontent.com/d/[FILE_ID] agar gambar dapat dimuat langsung di tag <img>.

    • Animasi: Gunakan transisi halus pada hover kartu (scale-105), durasi transisi minimal 300ms, dan efek fade-in pada elemen saat data selesai dimuat.

    • Error Handling: Tampilkan pesan bantuan yang ramah jika database kosong atau terjadi kegagalan koneksi server.

Fitur Utama

  • Upload Gambar: Simpan foto langsung ke Drive dan simpan URL publiknya di Sheets secara otomatis.

  • Responsif: Tampilan harus menyesuaikan secara sempurna di perangkat mobile, tablet, dan desktop (Mobile-First approach).

  • Navigasi: Gunakan link dinamis menggunakan scriptlet GAS <?= getAppUrl() ?> untuk mengelola perpindahan antar halaman tanpa hard-coded URL.

Petunjuk Eksekusi

  • Jalankan fungsi setupDatabase() satu kali untuk mendapatkan ID aset dan mengatur header kolom.

  • Deploy aplikasi sebagai Web App dengan pengaturan akses "Anyone" agar dapat diakses oleh pembaca umum.

  • Pastikan pengaturan berbagi (sharing) pada Folder Google Drive disetel ke "Anyone with the link can view" agar gambar dapat tampil di frontend.

Code.gs

/**
 * ==========================================
 * KONFIGURASI DATABASE & FOLDER
 * ==========================================
 * ID telah diperbarui berdasarkan URL yang Anda berikan.
 */
const FOLDER_FOTO_ID = '1LEtNq95-JF3MWLPBT0tLox4ipXLgeoF-';
const SS_ID_DATABASE = '1fKS_gHj2o5FiJbdto-j64EQcKl-h2YGdpXFeFv-pt1c';

/**
 * ROUTING MPA
 * Mengatur tampilan halaman berdasarkan parameter URL (?page=admin)
 */
function doGet(e) {
  let page = e.parameter.page || 'index';
  let templateName = (page === 'admin') ? 'Admin' : 'index';
 
  try {
    const template = HtmlService.createTemplateFromFile(templateName);
    return template.evaluate()
        .setTitle('FajarNews - ' + (page.charAt(0).toUpperCase() + page.slice(1)))
        .addMetaTag('viewport', 'width=device-width, initial-scale=1')
        .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
  } catch (err) {
    return HtmlService.createHtmlOutput("<h1>Halaman tidak ditemukan</h1><p>Pastikan Anda sudah membuat file <b>index.html</b> dan <b>Admin.html</b> di editor ini.</p>");
  }
}

/**
 * AMBIL DATA BERITA (Untuk Reader/Index)
 * Mengambil data dari Google Sheet dan mengirimkannya ke Frontend
 */
function getLatestNews() {
  try {
    const ss = SpreadsheetApp.openById(SS_ID_DATABASE);
    const sheet = ss.getSheetByName('Berita');
   
    if (!sheet) {
      console.error("Sheet dengan nama 'Berita' tidak ditemukan.");
      return [];
    }

    const range = sheet.getDataRange();
    const values = range.getValues();
   
    // Jika hanya ada header atau kosong
    if (values.length <= 1) {
      console.log("Database masih kosong.");
      return [];
    }

    values.shift(); // Hapus baris header
   
    // Filter baris kosong dan petakan ke objek yang bersih
    const data = values
      .filter(row => row[1] && row[1].toString().trim() !== "") // Pastikan Judul tidak kosong
      .map(row => {
        // Normalisasi Timestamp
        let ts = row[0];
        if (ts instanceof Date) {
          ts = ts.toISOString();
        } else if (!ts) {
          ts = new Date().toISOString();
        } else {
          ts = ts.toString();
        }

        return {
          timestamp: ts,
          judul: row[1] ? row[1].toString() : "",
          kategori: row[2] ? row[2].toString() : "Umum",
          isi: row[3] ? row[3].toString() : "",
          urlFoto: row[4] ? row[4].toString() : ""
        };
      })
      .reverse(); // Berita terbaru di urutan pertama

    console.log("Berhasil mengambil " + data.length + " berita.");
    return data;

  } catch (e) {
    console.error("Gagal memuat berita: " + e.message);
    throw new Error(e.message);
  }
}

/**
 * PROSES INPUT BERITA (Dari Dashboard Admin)
 */
function prosesInputBerita(data) {
  try {
    const ss = SpreadsheetApp.openById(SS_ID_DATABASE);
    const sheet = ss.getSheetByName('Berita');
    let urlFoto = '';

    if (data.fotoBase64 && data.fotoName) {
      const folder = DriveApp.getFolderById(FOLDER_FOTO_ID);
      const contentType = data.fotoBase64.split(';')[0].split(':')[1];
      const bytes = Utilities.base64Decode(data.fotoBase64.split(',')[1]);
      const blob = Utilities.newBlob(bytes, contentType, data.fotoName);
     
      const file = folder.createFile(blob);
      file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
     
      urlFoto = 'https://drive.google.com/uc?export=view&id=' + file.getId();
    }

    sheet.appendRow([
      new Date(),
      data.judul,
      data.kategori,
      data.isi,
      urlFoto
    ]);

    return {status: 'success', message: 'Berita berhasil diterbitkan!'};
  } catch (e) {
    console.error("Gagal simpan berita: " + e.toString());
    return {status: 'error', message: "Gagal menyimpan: " + e.toString()};
  }
}

/**
 * SETUP DATABASE & FOLDER
 * Jalankan ini jika ingin memastikan struktur kolom sudah benar.
 */
function setupDatabase() {
  try {
    const ss = SpreadsheetApp.openById(SS_ID_DATABASE);
    let sheet = ss.getSheetByName('Berita');
   
    if (!sheet) {
      sheet = ss.insertSheet('Berita');
    }
   
    const header = ['Timestamp', 'Judul', 'Kategori', 'Isi', 'UrlFoto'];
    sheet.getRange(1, 1, 1, header.length)
         .setValues([header])
         .setFontWeight('bold')
         .setBackground('#f3f3f3')
         .setBorder(true, true, true, true, true, true);

    return "Setup Berhasil. Struktur kolom telah disesuaikan.";
  } catch (e) {
    return "Gagal melakukan setup: " + e.toString();
  }
}

function getAppUrl() {
  return ScriptApp.getService().getUrl();
}

index.html

<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>FajarNews | Informasi Terkini & Terpercaya</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
  <script>
    tailwind.config = {
      theme: {
        extend: {
          fontFamily: {
            sans: ['Inter', 'sans-serif'],
            serif: ['Playfair Display', 'serif']
          },
          colors: {
            dark: '#0f172a',
            accent: '#0ea5e9'
          }
        }
      }
    }
  </script>
  <style>
    .hero-gradient {
      background: linear-gradient(to top, rgba(15, 23, 42, 0.95) 0%, rgba(15, 23, 42, 0.4) 50%, rgba(15, 23, 42, 0.1) 100%);
    }
    .fade-in { animation: fadeIn 0.8s ease-out forwards; }
    @keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
   
    /* Custom Scrollbar */
    ::-webkit-scrollbar { width: 8px; }
    ::-webkit-scrollbar-track { background: #f1f1f1; }
    ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
    ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
  </style>
</head>
<body class="bg-white text-gray-900 font-sans selection:bg-accent selection:text-white">

  <!-- Navigation -->
  <nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-xl border-b border-gray-100">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
      <div class="flex justify-between items-center h-20">
        <div class="flex items-center gap-12">
          <a href="#" onclick="location.reload()" class="text-3xl font-bold font-serif text-dark tracking-tighter">FAJAR<span class="text-accent italic">NEWS.</span></a>
          <div class="hidden lg:flex items-center space-x-8 text-[11px] font-black uppercase tracking-[0.2em] text-gray-400">
            <a href="#" class="text-accent">Terbaru</a>
            <a href="#" class="hover:text-dark transition-colors">Nasional</a>
            <a href="#" class="hover:text-dark transition-colors">Ekonomi</a>
            <a href="#" class="hover:text-dark transition-colors">Teknologi</a>
          </div>
        </div>
        <div class="flex items-center gap-6">
          <!-- Tombol Admin (Hanya aktif jika di lingkungan GAS) -->
          <div id="adminLinkContainer"></div>
        </div>
      </div>
    </div>
  </nav>

  <!-- Hero Section -->
  <header class="relative bg-white overflow-hidden">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
      <div id="hero-container" class="grid grid-cols-1 lg:grid-cols-12 gap-8">
        <!-- Skeleton Loader -->
        <div class="lg:col-span-8 h-[550px] bg-slate-100 animate-pulse rounded-[2.5rem]"></div>
        <div class="lg:col-span-4 h-[550px] bg-slate-50 animate-pulse rounded-[2.5rem]"></div>
      </div>
    </div>
  </header>

  <!-- News Grid Feed -->
  <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
    <div class="flex items-center justify-between mb-12">
      <div class="flex items-center gap-4">
        <div class="w-10 h-1 bg-accent rounded-full"></div>
        <h2 class="text-3xl font-bold font-serif text-dark">Kabar <span class="italic text-accent">Lainnya</span></h2>
      </div>
      <span class="text-[10px] font-black uppercase tracking-[0.3em] text-slate-300">Update Terkini</span>
    </div>

    <div id="news-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-12">
      <!-- Loading Skeletons -->
      <div class="space-y-4"><div class="aspect-video bg-slate-100 animate-pulse rounded-3xl"></div><div class="h-6 w-full bg-slate-100 animate-pulse rounded-full"></div></div>
      <div class="space-y-4"><div class="aspect-video bg-slate-100 animate-pulse rounded-3xl"></div><div class="h-6 w-full bg-slate-100 animate-pulse rounded-full"></div></div>
      <div class="space-y-4"><div class="aspect-video bg-slate-100 animate-pulse rounded-3xl"></div><div class="h-6 w-full bg-slate-100 animate-pulse rounded-full"></div></div>
    </div>
  </main>

  <!-- Detail Modal -->
  <div id="newsModal" class="fixed inset-0 z-[100] hidden overflow-y-auto bg-dark/60 backdrop-blur-sm p-4 md:p-8">
    <div class="relative mx-auto max-w-4xl bg-white rounded-[2.5rem] shadow-2xl overflow-hidden fade-in">
      <button onclick="closeModal()" class="absolute top-6 right-6 z-10 p-2 bg-white/80 backdrop-blur rounded-full shadow-lg hover:bg-white transition-colors">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
      </button>
      <div id="modalContent">
        <!-- Injected by JS -->
      </div>
    </div>
  </div>

  <footer class="bg-dark text-slate-500 py-20 border-t border-slate-900">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
      <a href="#" class="text-2xl font-bold font-serif text-white tracking-tighter mb-8 inline-block">FAJAR<span class="text-accent italic">NEWS.</span></a>
      <p class="text-sm max-w-xl mx-auto mb-10 leading-relaxed">Menyajikan informasi tercepat dan terpercaya untuk Anda setiap hari. Kami berkomitmen untuk menjaga integritas jurnalisme di era digital.</p>
      <div class="flex justify-center space-x-8 text-[10px] font-bold uppercase tracking-widest">
        <a href="#" class="hover:text-white transition-colors">Tentang Kami</a>
        <a href="#" class="hover:text-white transition-colors">Redaksi</a>
        <a href="#" class="hover:text-white transition-colors">Kontak</a>
      </div>
      <div class="mt-20 pt-8 border-t border-slate-800">
        <p class="text-[10px] font-bold uppercase tracking-[0.3em]">© 2026 FajarNews Media Group. Hak Cipta Dilindungi.</p>
      </div>
    </div>
  </footer>

  <script>
    let newsDataGlobal = [];

    /**
     * Fungsi utilitas untuk mengekstrak ID File dan mengubahnya menjadi
     * URL yang bisa ditampilkan langsung di browser (Direct Link).
     */
    function formatPhotoUrl(url) {
      if (!url) return 'https://images.unsplash.com/photo-1504711432869-0df8b93be95f?auto=format&fit=crop&q=80&w=1200';
     
      let fileId = '';

      // Cek apakah ini URL Drive (format: id=xxx atau /d/xxx/)
      if (url.includes('id=')) {
        fileId = url.split('id=')[1].split('&')[0];
      } else if (url.includes('drive.google.com/file/d/')) {
        fileId = url.split('/d/')[1].split('/')[0];
      } else if (url.includes('drive.google.com/uc?')) {
        // Handle format uc?id=
        const urlParams = new URLSearchParams(url.split('?')[1]);
        fileId = urlParams.get('id');
      } else if (!url.startsWith('http')) {
        // Jika hanya ID yang dikirim
        fileId = url;
      }

      // Jika ID ditemukan, gunakan lh3 server (lebih stabil untuk <img>)
      if (fileId) {
        return 'https://lh3.googleusercontent.com/d/' + fileId;
      }
     
      return url;
    }

    window.onload = function() {
      if (typeof google !== 'undefined' && google.script && google.script.run) {
        document.getElementById('adminLinkContainer').innerHTML = `
          <a href="<?= typeof getAppUrl !== 'undefined' ? getAppUrl() + '?page=admin' : '#' ?>" class="hidden sm:flex items-center gap-2 px-6 py-2.5 bg-dark text-white text-[10px] font-bold uppercase tracking-widest rounded-full hover:bg-slate-800 transition-all shadow-lg shadow-slate-200">
            <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>
            Editor Panel
          </a>
        `;

        google.script.run
          .withSuccessHandler(renderPage)
          .withFailureHandler(err => {
            console.error("Gagal memuat berita dari server:", err);
            showErrorUI("Gagal memuat data dari Spreadsheet.");
          })
          .getLatestNews();
      } else {
        console.warn("Objek 'google' tidak ditemukan. Menggunakan data dummy.");
        const dummyData = [
          {
            timestamp: new Date().toISOString(),
            judul: "Selamat Datang di Pratinjau FajarNews",
            kategori: "Info",
            isi: "Data ini adalah contoh. Saat Anda men-deploy aplikasi ini, berita akan diambil secara otomatis dari Google Spreadsheet Anda.",
            urlFoto: "https://images.unsplash.com/photo-1504711432869-0df8b93be95f?auto=format&fit=crop&q=80&w=1200"
          },
          {
            timestamp: new Date().toISOString(),
            judul: "Terobosan Baru dalam Jurnalisme Digital",
            kategori: "Teknologi",
            isi: "Platform ini menggunakan integrasi Google Apps Script yang memungkinkan pengelolaan konten tanpa perlu server tambahan.",
            urlFoto: "https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?auto=format&fit=crop&q=80&w=800"
          }
        ];
        setTimeout(() => renderPage(dummyData), 1000);
      }
    };

    function showErrorUI(message) {
      document.getElementById('hero-container').innerHTML = `
        <div class="col-span-12 py-32 text-center border-2 border-dashed border-slate-100 rounded-[2.5rem]">
          <p class="text-slate-400 font-medium">${message}</p>
        </div>`;
    }

    function renderPage(data) {
      newsDataGlobal = data;
      const hero = document.getElementById('hero-container');
      const grid = document.getElementById('news-grid');
     
      if (!data || data.length === 0) {
        hero.innerHTML = `
          <div class="col-span-12 py-32 text-center bg-slate-50 rounded-[2.5rem] border border-slate-100">
            <h3 class="text-2xl font-bold font-serif text-slate-400 mb-4">Belum Ada Berita</h3>
            <p class="text-slate-400 text-sm mb-8">Redaksi kami sedang menyiapkan informasi menarik untuk Anda.</p>
          </div>`;
        grid.innerHTML = '';
        return;
      }

      const latest = data[0];
      const heroImg = formatPhotoUrl(latest.urlFoto);
     
      hero.innerHTML = `
        <div onclick="openModal(0)" class="lg:col-span-8 relative rounded-[2.5rem] overflow-hidden group h-[550px] shadow-2xl shadow-sky-100 fade-in cursor-pointer">
          <img src="${heroImg}" class="absolute inset-0 w-full h-full object-cover transition duration-1000 group-hover:scale-105" onerror="this.src='https://via.placeholder.com/800x550?text=Gambar+Tidak+Tersedia'">
          <div class="absolute inset-0 hero-gradient"></div>
          <div class="absolute bottom-0 p-8 md:p-14 text-white">
            <span class="bg-accent/90 backdrop-blur-md px-5 py-2 rounded-full text-[10px] font-black uppercase tracking-[0.2em] mb-6 inline-block shadow-xl">${latest.kategori}</span>
            <h1 class="text-4xl md:text-6xl font-bold font-serif leading-tight mb-6 max-w-4xl">${latest.judul}</h1>
            <p class="text-slate-200 text-sm md:text-lg line-clamp-3 max-w-2xl font-light leading-relaxed">${latest.isi}</p>
          </div>
        </div>
        <div class="lg:col-span-4 flex flex-col gap-6 fade-in" style="animation-delay: 0.1s">
          <div class="bg-slate-50 p-10 rounded-[2.5rem] border border-slate-100 flex-1 flex flex-col justify-between shadow-sm">
            <div>
              <h3 class="text-[11px] font-black uppercase tracking-[0.3em] text-slate-300 mb-10 pb-2 border-b">Top Trending</h3>
              <div class="space-y-10">
                ${data.slice(1, 5).map((n, i) => `
                  <div onclick="openModal(${i+1})" class="flex gap-6 group cursor-pointer">
                    <span class="text-5xl font-black text-slate-100 group-hover:text-accent transition-colors duration-300">${i+1}</span>
                    <div class="pt-1">
                      <h4 class="text-sm font-bold leading-snug text-dark group-hover:text-accent transition-colors duration-300 line-clamp-2">${n.judul}</h4>
                      <span class="text-[9px] font-black text-slate-300 uppercase tracking-widest mt-3 block">${n.kategori}</span>
                    </div>
                  </div>
                `).join('') || '<p class="text-slate-300 italic text-sm text-center">Nantikan berita populer lainnya.</p>'}
              </div>
            </div>
          </div>
        </div>
      `;

      if (data.length > 1) {
        grid.innerHTML = data.slice(1).map((n, i) => {
          const thumb = formatPhotoUrl(n.urlFoto);
          const dateStr = new Date(n.timestamp).toLocaleDateString('id-ID', { day: 'numeric', month: 'long' });
          return `
            <article onclick="openModal(${i+1})" class="group cursor-pointer fade-in" style="animation-delay: ${0.2 + (i * 0.1)}s">
              <div class="relative overflow-hidden rounded-[2rem] aspect-[16/11] mb-8 shadow-sm group-hover:shadow-xl transition-all duration-500">
                <img src="${thumb}" class="w-full h-full object-cover group-hover:scale-110 transition duration-700" onerror="this.src='https://via.placeholder.com/600x400?text=No+Image'">
                <div class="absolute top-5 left-5">
                  <span class="bg-white/95 backdrop-blur-md px-4 py-1.5 rounded-full text-[9px] font-black uppercase tracking-widest text-dark shadow-sm">${n.kategori}</span>
                </div>
              </div>
              <h3 class="text-2xl font-bold font-serif leading-tight mb-5 text-dark group-hover:text-accent transition-colors duration-300 line-clamp-2">${n.judul}</h3>
              <p class="text-slate-500 text-sm line-clamp-4 mb-8 leading-relaxed font-light">${n.isi}</p>
              <div class="flex items-center justify-between text-[10px] font-black uppercase tracking-widest text-slate-300 border-t border-slate-100 pt-6">
                <span>4 Min Baca</span>
                <span class="text-slate-400 font-bold">${dateStr}</span>
              </div>
            </article>
          `;
        }).join('');
      } else {
        grid.innerHTML = `<div class="col-span-full py-20 text-center border-2 border-dashed border-slate-100 rounded-[2.5rem] text-slate-300 italic">Berita lainnya akan segera hadir.</div>`;
      }
    }

    function openModal(index) {
      if (!newsDataGlobal[index]) return;
      const item = newsDataGlobal[index];
      const modal = document.getElementById('newsModal');
      const content = document.getElementById('modalContent');
      const dateStr = new Date(item.timestamp).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' });
      const img = formatPhotoUrl(item.urlFoto);

      content.innerHTML = `
        <img src="${img}" class="w-full h-[300px] md:h-[450px] object-cover" onerror="this.src='https://via.placeholder.com/1200x600?text=Gambar+Tidak+Tersedia'">
        <div class="p-8 md:p-14">
          <div class="flex items-center gap-4 mb-8">
            <span class="bg-accent text-white px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest shadow-lg shadow-sky-100">${item.kategori}</span>
            <span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">${dateStr}</span>
          </div>
          <h2 class="text-3xl md:text-5xl font-bold font-serif leading-tight text-dark mb-10">${item.judul}</h2>
          <div class="prose prose-slate max-w-none">
            <p class="text-slate-600 text-lg md:text-xl leading-relaxed font-light whitespace-pre-line">${item.isi}</p>
          </div>
        </div>
      `;
     
      modal.classList.remove('hidden');
      document.body.style.overflow = 'hidden';
    }

    function closeModal() {
      const modal = document.getElementById('newsModal');
      modal.classList.add('hidden');
      document.body.style.overflow = 'auto';
    }

    window.onclick = function(event) {
      const modal = document.getElementById('newsModal');
      if (event.target == modal) {
        closeModal();
      }
    }
  </script>
</body>
</html>

Admin.html

<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
  <style>
    body { font-family: 'Inter', sans-serif; }
    .loader { border-top-color: #0ea5e9; animation: spin 1s linear infinite; }
    @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
  </style>
</head>
<body class="bg-slate-50 min-h-screen">

  <nav class="bg-white border-b px-8 py-4 flex justify-between items-center sticky top-0 z-10">
    <div class="flex items-center gap-4">
      <a href="<?= getAppUrl() ?>?page=index" class="p-2 hover:bg-slate-100 rounded-lg transition">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
      </a>
      <h1 class="text-xl font-bold text-slate-800 tracking-tight">Dashboard Editor</h1>
    </div>
    <div class="flex items-center gap-4">
      <span class="text-xs font-semibold text-slate-400 uppercase tracking-widest">News Portal v1.0</span>
      <div class="h-8 w-8 bg-slate-200 rounded-full"></div>
    </div>
  </nav>

  <div class="max-w-6xl mx-auto p-8">
    <div id="notif" class="hidden mb-6 p-4 rounded-xl text-white font-medium text-sm"></div>

    <form id="newsForm" onsubmit="event.preventDefault(); handleUpload(this);" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
     
      <!-- Utama -->
      <div class="lg:col-span-2 space-y-6">
        <div class="bg-white p-8 rounded-3xl shadow-sm border border-slate-100">
          <label class="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Judul Artikel</label>
          <input type="text" name="judul" required placeholder="Ketikkan judul yang menarik..."
            class="w-full text-2xl font-bold border-none focus:ring-0 outline-none placeholder:text-slate-200">
         
          <div class="h-[1px] bg-slate-100 my-6"></div>
         
          <label class="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-2">Isi Konten</label>
          <textarea name="isi" required rows="12" placeholder="Mulai menulis cerita Anda di sini..."
            class="w-full border-none focus:ring-0 outline-none resize-none text-slate-600 leading-relaxed"></textarea>
        </div>
      </div>

      <!-- Sidebar -->
      <div class="space-y-6">
        <div class="bg-white p-6 rounded-3xl shadow-sm border border-slate-100">
          <label class="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Gambar Unggulan</label>
         
          <div id="previewContainer" class="hidden relative mb-4 rounded-2xl overflow-hidden shadow-inner">
            <img id="imgPreview" src="#" class="w-full aspect-video object-cover">
            <button type="button" onclick="resetImg()" class="absolute top-2 right-2 bg-red-500 text-white p-1.5 rounded-full hover:bg-red-600">
              <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
            </button>
          </div>

          <label id="dropzone" class="flex flex-col items-center justify-center border-2 border-dashed border-slate-200 rounded-2xl py-10 px-4 bg-slate-50 hover:bg-slate-100 hover:border-sky-400 cursor-pointer transition">
            <svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-slate-300 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 002-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
            <span class="text-xs font-medium text-slate-500">Pilih Foto</span>
            <input type="file" id="fotoFile" accept="image/*" class="hidden" onchange="previewFile()">
          </label>
        </div>

        <div class="bg-white p-6 rounded-3xl shadow-sm border border-slate-100">
          <label class="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Kategori</label>
          <select name="kategori" required class="w-full bg-slate-50 border border-slate-100 rounded-xl p-3 text-sm focus:ring-2 focus:ring-sky-400 outline-none transition">
            <option value="Politik">Politik</option>
            <option value="Bisnis">Bisnis</option>
            <option value="Teknologi">Teknologi</option>
            <option value="Hiburan">Hiburan</option>
            <option value="Olahraga">Olahraga</option>
          </select>
        </div>

        <button type="submit" id="btnS" class="w-full bg-sky-500 hover:bg-sky-600 text-white font-bold py-4 rounded-2xl shadow-lg shadow-sky-200 transition-all flex justify-center items-center gap-3">
          <span>Terbitkan Berita</span>
        </button>
      </div>
    </form>
  </div>

  <div id="loading" class="hidden fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center">
    <div class="bg-white p-8 rounded-3xl flex flex-col items-center">
      <div class="loader ease-linear rounded-full border-4 border-t-4 border-slate-100 h-12 w-12 mb-4"></div>
      <p class="font-bold text-slate-800">Sedang Mengunggah...</p>
    </div>
  </div>

  <script>
    function previewFile() {
      const file = document.getElementById('fotoFile').files[0];
      const preview = document.getElementById('imgPreview');
      const container = document.getElementById('previewContainer');
      const dropzone = document.getElementById('dropzone');
      const reader = new FileReader();

      if (file) {
        reader.onloadend = () => {
          preview.src = reader.result;
          container.classList.remove('hidden');
          dropzone.classList.add('hidden');
        };
        reader.readAsDataURL(file);
      }
    }

    function resetImg() {
      document.getElementById('fotoFile').value = "";
      document.getElementById('previewContainer').classList.add('hidden');
      document.getElementById('dropzone').classList.remove('hidden');
    }

    function handleUpload(form) {
      const loading = document.getElementById('loading');
      const fileInput = document.getElementById('fotoFile');
      const btn = document.getElementById('btnS');
     
      loading.classList.remove('hidden');
      btn.disabled = true;

      const payload = {
        judul: form.judul.value,
        isi: form.isi.value,
        kategori: form.kategori.value,
        fotoBase64: null,
        fotoName: null
      };

      if (fileInput.files.length > 0) {
        const file = fileInput.files[0];
        const reader = new FileReader();
        reader.onloadend = () => {
          payload.fotoBase64 = reader.result;
          payload.fotoName = file.name;
          sendToGas(payload);
        };
        reader.readAsDataURL(file);
      } else {
        sendToGas(payload);
      }
    }

    function sendToGas(data) {
      google.script.run
        .withSuccessHandler(res => {
          document.getElementById('loading').classList.add('hidden');
          const notif = document.getElementById('notif');
          notif.textContent = res.message;
          notif.className = `mb-6 p-4 rounded-xl text-white font-medium text-sm ${res.status === 'success' ? 'bg-emerald-500' : 'bg-rose-500'}`;
          notif.classList.remove('hidden');
          if(res.status === 'success') {
            document.getElementById('newsForm').reset();
            resetImg();
          }
          document.getElementById('btnS').disabled = false;
        })
        .prosesInputBerita(data);
    }
  </script>
</body>
</html>


Tidak ada komentar:

Posting Komentar

MPA - Sales dan Payment

  https://script.google.com/macros/s/AKfycbzZiOuWv9vMWKHS1VcQyhSZ2heKjAAzsVV80cDtAD1U78pc8JIVVjyinE2y0FHfpODq/exec?page=checkout https://scr...