Sabtu, 09 Mei 2026

React - Latihan 1

 






PROMPT

Buatkan aplikasi web dengan menggunakan jsx dan google apps script.
Url spreadsheet "https://docs.google.com/spreadsheets/d/16cBgEFpZKVXdwIL6wCUVXmwOVSkLKefyHxAPPO46Mg0/edit?gid=0#gid=0"
Url Folder Foto "https://drive.google.com/drive/folders/1i2WQ9ri469qfagNW3ceyjpM56HYSM-Mm?hl=ID"

Untuk Backend (Google Apps Script)
Context: Saya ingin membuat backend menggunakan Google Apps Script yang berfungsi sebagai API untuk aplikasi manajemen pelatihan. Database menggunakan Google Sheets.

Requirements:

Buat fungsi setupDatabase, doPost(e) dan doGet(e) untuk menangani operasi CRUD (Create, Read, Update, Delete).

Struktur Sheet:

Sheet "Pelatihan": ID, Nama Pelatihan, Pemateri, Tanggal, Kuota, Status.

Sheet "Peserta": ID Peserta, ID Pelatihan, Nama Peserta, Email, Institusi,Foto.

Fitur Keamanan & Teknis:

Gunakan ContentService.createTextOutput() dengan JSON stringify.

Gunakan format URL gambar lh3.googleusercontent.com jika ada upload file.

Implementasikan sistem ID unik (UUID atau Timestamp) untuk setiap entri baru.

Pastikan script menangani pengiriman data dari frontend yang menggunakan mode no-cors atau berikan header akses jika diperlukan.

Kembalikan respons sukses/error yang jelas dalam format JSON.

Untuk Frontend gunakan React & Tailwind CSS
Context: Saya ingin membangun UI untuk Aplikasi Manajemen Pelatihan menggunakan React dan Tailwind CSS dengan konsep desain modern dan minimalis.

Requirements:

Routing & Struktur:

Gunakan react-router-dom.

Halaman Utama (Dashboard): Statistik ringkas (Total Pelatihan, Total Peserta).

Halaman Daftar Pelatihan: Tabel/Card yang menampilkan daftar pelatihan dengan filter status.

Halaman Admin: Form untuk menambah/edit pelatihan dan daftar peserta per pelatihan (pisahkan jika perlu menjadi admin.html atau rute terproteksi).

Integrasi API:

Gunakan fetch API untuk berkomunikasi dengan URL Google Apps Script.

Implementasikan mode: 'no-cors' pada request POST untuk menghindari masalah kebijakan Google.

Buat fungsi uploadFile khusus untuk mengirim file/foto ke Google Drive melalui Apps Script.

UI/UX:

Gunakan palet warna profesional (contoh: Biru Navy dan Putih).

Gunakan Lucide React untuk ikon.

Pastikan form input memiliki validasi sederhana dan loading state saat mengirim data.

Gunakan sistem SPA (Single Page Application) yang ringan.

Panduan Membuat Project React

Ikuti langkah-langkah ini untuk menjalankan aplikasi Manajemen Pelatihan Anda.

1. Persiapan Awal

Pastikan Anda sudah menginstal Node.js di komputer Anda.

  • Cek dengan cara buka Terminal/Command Prompt dan ketik: node -v

  • Jika belum punya, download di nodejs.org.

2. Membuat Project Baru

Buka Terminal di folder tempat Anda ingin menyimpan project, lalu jalankan perintah:

npm create vite@latest e-latih -- --template react

(Ganti e-latih dengan nama project yang Anda inginkan)

3. Masuk ke Folder dan Buka VS Code

Jalankan perintah berikut secara berurutan:

cd e-latih
code .

Sekarang VS Code akan terbuka otomatis di folder project tersebut.

4. Instalasi Library (Dependency)

Di dalam terminal VS Code (Tekan Ctrl + ~), jalankan perintah untuk menginstal library dasar:

npm install

Kemudian, instal library tambahan yang dibutuhkan oleh kode kita (Lucide React):

npm install lucide-react

5. Instalasi Tailwind CSS

Karena kode aplikasi menggunakan Tailwind, kita harus menginstalnya terlebih dahulu:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Buka file tailwind.config.js dan ubah bagian content menjadi:

content: [
  "./index.html",
  "./src/**/*.{js,ts,jsx,tsx}",
],

Buka file src/index.css dan ganti isinya dengan:

@tailwind base;
@tailwind components;
@tailwind utilities;

6. Memasukkan Kode Aplikasi

  1. Buka file src/App.jsx.

  2. Hapus semua isinya.

  3. Salin dan tempel (Paste) seluruh kode Aplikasi Manajemen Pelatihan yang saya berikan sebelumnya ke dalam file tersebut.

  4. Jangan lupa: Ganti variabel API_URL dengan URL Web App dari Google Apps Script Anda.

7. Menjalankan Aplikasi

Kembali ke terminal VS Code, jalankan perintah:

npm run dev

Klik link (biasanya http://localhost:5173) yang muncul di terminal untuk membuka aplikasi di browser Anda.

Back End - Apps Script

/**
 * Konfigurasi Global
 */
const SPREADSHEET_ID = '16cBgEFpZKVXdwIL6wCUVXmwOVSkLKefyHxAPPO46Mg0';
const FOLDER_ID = '1i2WQ9ri469qfagNW3ceyjpM56HYSM-Mm'; // Diambil dari URL folder Anda

/**
 * Setup Database: Membuat sheet jika belum ada
 */
function setupDatabase() {
  const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
 
  if (!ss.getSheetByName("Pelatihan")) {
    ss.insertSheet("Pelatihan").appendRow(["ID", "Nama Pelatihan", "Pemateri", "Tanggal", "Kuota", "Status"]);
  }
 
  if (!ss.getSheetByName("Peserta")) {
    ss.insertSheet("Peserta").appendRow(["ID Peserta", "ID Pelatihan", "Nama Peserta", "Email", "Institusi", "Foto"]);
  }
 
  return "Database Berhasil Disiapkan!";
}

/**
 * Menangani request GET (Read Data)
 */
function doGet(e) {
  const action = e.parameter.action;
  const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
 
  try {
    let data;
    if (action === 'getPelatihan') {
      const sheet = ss.getSheetByName("Pelatihan");
      const values = sheet.getDataRange().getValues();
      const headers = values[0];
      data = values.slice(1).map(row => {
        let obj = {};
        headers.forEach((h, i) => obj[h] = row[i]);
        return obj;
      });
    } else if (action === 'getPeserta') {
      const sheet = ss.getSheetByName("Peserta");
      const values = sheet.getDataRange().getValues();
      const headers = values[0];
      data = values.slice(1).map(row => {
        let obj = {};
        headers.forEach((h, i) => obj[h] = row[i]);
        return obj;
      });
    }

    return ContentService.createTextOutput(JSON.stringify({ status: 'success', data: data }))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: error.toString() }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

/**
 * Menangani request POST (Create, Update, Delete)
 */
function doPost(e) {
  const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
  const body = JSON.parse(e.postData.contents);
  const action = body.action;

  try {
    if (action === 'addPelatihan') {
      const sheet = ss.getSheetByName("Pelatihan");
      const id = "TRN-" + new Date().getTime();
      sheet.appendRow([id, body.nama, body.pemateri, body.tanggal, body.kuota, body.status]);
      return createResponse({ status: 'success', id: id });
    }
   
    else if (action === 'addPeserta') {
      const sheet = ss.getSheetByName("Peserta");
      const id = "USR-" + new Date().getTime();
      let photoUrl = "";
     
      if (body.fotoBase64) {
        photoUrl = uploadToDrive(body.fotoBase64, body.fotoName, id);
      }
     
      sheet.appendRow([id, body.idPelatihan, body.nama, body.email, body.institusi, photoUrl]);
      return createResponse({ status: 'success', id: id });
    }

    else if (action === 'deletePelatihan') {
      const sheet = ss.getSheetByName("Pelatihan");
      const data = sheet.getDataRange().getValues();
      for (let i = 1; i < data.length; i++) {
        if (data[i][0] === body.id) {
          sheet.deleteRow(i + 1);
          break;
        }
      }
      return createResponse({ status: 'success' });
    }

  } catch (error) {
    return createResponse({ status: 'error', message: error.toString() });
  }
}

/**
 * Helper untuk upload file ke Google Drive
 */
function uploadToDrive(base64Data, fileName, userId) {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const contentType = base64Data.substring(5, base64Data.indexOf(';'));
  const bytes = Utilities.base64Decode(base64Data.split(',')[1]);
  const blob = Utilities.newBlob(bytes, contentType, fileName || `foto_${userId}.png`);
  const file = folder.createFile(blob);
  file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
 
  // Mengembalikan format URL yang bisa langsung ditampilkan
  return `https://lh3.googleusercontent.com/d/${file.getId()}`;
}

/**
 * Helper untuk response JSON
 */
function createResponse(payload) {
  return ContentService.createTextOutput(JSON.stringify(payload))
    .setMimeType(ContentService.MimeType.JSON);
}

Front End

import React, { useState, useEffect } from 'react';
import {
  LayoutDashboard,
  Users,
  PlusCircle,
  BookOpen,
  Trash2,
  Camera,
  Loader2,
  ChevronRight,
  Filter,
  CheckCircle2,
  XCircle
} from 'lucide-react';

// KONFIGURASI API - Masukkan URL Web App dari Apps Script Anda di sini
const API_URL = "MASUKKAN_URL_WEB_APP_ANDA_DI_SINI";

const App = () => {
  const [currentView, setCurrentView] = useState('dashboard');
  const [loading, setLoading] = useState(false);
  const [pelatihan, setPelatihan] = useState([]);
  const [peserta, setPeserta] = useState([]);
  const [statusFilter, setStatusFilter] = useState('Semua');

  // Fetch data awal
  useEffect(() => {
    fetchData();
  }, []);

  const fetchData = async () => {
    setLoading(true);
    try {
      const resPelatihan = await fetch(`${API_URL}?action=getPelatihan`);
      const dataPelatihan = await resPelatihan.json();
      if(dataPelatihan.status === 'success') setPelatihan(dataPelatihan.data);

      const resPeserta = await fetch(`${API_URL}?action=getPeserta`);
      const dataPeserta = await resPeserta.json();
      if(dataPeserta.status === 'success') setPeserta(dataPeserta.data);
    } catch (err) {
      console.error("Gagal mengambil data", err);
    } finally {
      setLoading(false);
    }
  };

  // Komponen Navigasi
  const NavItem = ({ id, icon: Icon, label }) => (
    <button
      onClick={() => setCurrentView(id)}
      className={`flex items-center space-x-3 w-full p-3 rounded-lg transition-all ${
        currentView === id ? 'bg-navy-700 text-white shadow-lg' : 'text-slate-400 hover:bg-slate-800'
      }`}
    >
      <Icon size={20} />
      <span className="font-medium">{label}</span>
    </button>
  );

  return (
    <div className="flex h-screen bg-slate-50 text-slate-900 font-sans">
      {/* Sidebar */}
      <aside className="w-64 bg-slate-900 text-white p-6 hidden md:block">
        <div className="flex items-center space-x-3 mb-10 px-2">
          <div className="bg-blue-500 p-2 rounded-lg">
            <BookOpen size={24} className="text-white" />
          </div>
          <h1 className="text-xl font-bold tracking-tight">E-Latih</h1>
        </div>
       
        <nav className="space-y-2">
          <NavItem id="dashboard" icon={LayoutDashboard} label="Dashboard" />
          <NavItem id="pelatihan" icon={BookOpen} label="Daftar Pelatihan" />
          <NavItem id="admin" icon={PlusCircle} label="Manajemen Admin" />
        </nav>
      </aside>

      {/* Main Content */}
      <main className="flex-1 overflow-y-auto">
        <header className="bg-white border-b border-slate-200 p-4 sticky top-0 z-10 flex justify-between items-center">
          <h2 className="text-lg font-semibold capitalize">{currentView.replace('-', ' ')}</h2>
          <div className="flex items-center space-x-4">
            {loading && <Loader2 className="animate-spin text-blue-600" size={20} />}
            <div className="h-8 w-8 rounded-full bg-slate-200"></div>
          </div>
        </header>

        <div className="p-6 max-w-6xl mx-auto">
          {currentView === 'dashboard' && (
            <DashboardStats pelatihan={pelatihan} peserta={peserta} />
          )}
          {currentView === 'pelatihan' && (
            <PelatihanList
              data={pelatihan}
              filter={statusFilter}
              setFilter={setStatusFilter}
            />
          )}
          {currentView === 'admin' && (
            <AdminPanel
              pelatihan={pelatihan}
              peserta={peserta}
              onRefresh={fetchData}
            />
          )}
        </div>
      </main>

      {/* Mobile Nav */}
      <div className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 flex justify-around p-3">
        <button onClick={() => setCurrentView('dashboard')} className={currentView === 'dashboard' ? 'text-blue-600' : 'text-slate-400'}>
          <LayoutDashboard size={24} />
        </button>
        <button onClick={() => setCurrentView('pelatihan')} className={currentView === 'pelatihan' ? 'text-blue-600' : 'text-slate-400'}>
          <BookOpen size={24} />
        </button>
        <button onClick={() => setCurrentView('admin')} className={currentView === 'admin' ? 'text-blue-600' : 'text-slate-400'}>
          <PlusCircle size={24} />
        </button>
      </div>
    </div>
  );
};

// --- SUB-KOMPONEN ---

const DashboardStats = ({ pelatihan, peserta }) => {
  const activePelatihan = pelatihan.filter(p => p.Status === 'Aktif').length;
 
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      <StatCard title="Total Pelatihan" value={pelatihan.length} icon={BookOpen} color="blue" />
      <StatCard title="Pelatihan Aktif" value={activePelatihan} icon={CheckCircle2} color="green" />
      <StatCard title="Total Peserta" value={peserta.length} icon={Users} color="purple" />
     
      <div className="md:col-span-3 bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
        <h3 className="text-lg font-bold mb-4">Pelatihan Terbaru</h3>
        <div className="space-y-4">
          {pelatihan.slice(0, 3).map((p, i) => (
            <div key={i} className="flex justify-between items-center p-3 hover:bg-slate-50 rounded-lg border border-transparent hover:border-slate-100 transition-all">
              <div>
                <p className="font-semibold text-slate-800">{p['Nama Pelatihan']}</p>
                <p className="text-sm text-slate-500">{p.Pemateri} {p.Tanggal}</p>
              </div>
              <ChevronRight className="text-slate-300" />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

const StatCard = ({ title, value, icon: Icon, color }) => {
  const colors = {
    blue: 'bg-blue-50 text-blue-600',
    green: 'bg-emerald-50 text-emerald-600',
    purple: 'bg-violet-50 text-violet-600'
  };
 
  return (
    <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
      <div className="flex justify-between items-start">
        <div>
          <p className="text-sm font-medium text-slate-500 uppercase tracking-wider">{title}</p>
          <h4 className="text-3xl font-bold mt-2 text-slate-900">{value}</h4>
        </div>
        <div className={`p-3 rounded-xl ${colors[color]}`}>
          <Icon size={24} />
        </div>
      </div>
    </div>
  );
};

const PelatihanList = ({ data, filter, setFilter }) => {
  const filteredData = filter === 'Semua' ? data : data.filter(p => p.Status === filter);
 
  return (
    <div className="space-y-6">
      <div className="flex items-center space-x-2 overflow-x-auto pb-2">
        {['Semua', 'Aktif', 'Selesai', 'Mendatang'].map(f => (
          <button
            key={f}
            onClick={() => setFilter(f)}
            className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-all ${
              filter === f ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'
            }`}
          >
            {f}
          </button>
        ))}
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {filteredData.map((p, i) => (
          <div key={i} className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden hover:shadow-md transition-all">
            <div className={`h-2 ${p.Status === 'Aktif' ? 'bg-green-500' : 'bg-slate-400'}`}></div>
            <div className="p-5">
              <div className="flex justify-between items-start mb-3">
                <span className={`text-[10px] uppercase font-bold px-2 py-1 rounded-md ${
                  p.Status === 'Aktif' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'
                }`}>
                  {p.Status}
                </span>
                <p className="text-xs text-slate-400">ID: {p.ID}</p>
              </div>
              <h4 className="text-lg font-bold text-slate-800 mb-1">{p['Nama Pelatihan']}</h4>
              <p className="text-sm text-slate-500 mb-4">{p.Pemateri}</p>
             
              <div className="grid grid-cols-2 gap-4 text-sm text-slate-600">
                <div className="flex items-center space-x-2">
                  <LayoutDashboard size={14} className="text-slate-400" />
                  <span>{p.Tanggal}</span>
                </div>
                <div className="flex items-center space-x-2">
                  <Users size={14} className="text-slate-400" />
                  <span>{p.Kuota} Kuota</span>
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};

const AdminPanel = ({ pelatihan, peserta, onRefresh }) => {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [activeTab, setActiveTab] = useState('tambah'); // 'tambah' or 'daftar-peserta'
  const [formData, setFormData] = useState({
    nama: '', pemateri: '', tanggal: '', kuota: '', status: 'Aktif'
  });

  const handleSubmitPelatihan = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    try {
      await fetch(API_URL, {
        method: 'POST',
        mode: 'no-cors',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'addPelatihan', ...formData })
      });
      setFormData({ nama: '', pemateri: '', tanggal: '', kuota: '', status: 'Aktif' });
      // Karena no-cors, kita tidak bisa membaca response. Asumsikan sukses dan refresh
      setTimeout(() => onRefresh(), 2000);
    } catch (err) {
      console.error(err);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
      <div className="flex border-b border-slate-200">
        <button
          onClick={() => setActiveTab('tambah')}
          className={`flex-1 py-4 text-sm font-bold ${activeTab === 'tambah' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}
        >
          TAMBAH PELATIHAN
        </button>
        <button
          onClick={() => setActiveTab('peserta')}
          className={`flex-1 py-4 text-sm font-bold ${activeTab === 'peserta' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}
        >
          MANAJEMEN PESERTA
        </button>
      </div>

      <div className="p-8">
        {activeTab === 'tambah' ? (
          <form onSubmit={handleSubmitPelatihan} className="space-y-6 max-w-2xl">
            <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
              <div className="space-y-2">
                <label className="text-sm font-bold text-slate-700">Nama Pelatihan</label>
                <input
                  required
                  type="text"
                  value={formData.nama}
                  onChange={(e) => setFormData({...formData, nama: e.target.value})}
                  className="w-full p-3 rounded-lg border border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
                  placeholder="Contoh: React Masterclass"
                />
              </div>
              <div className="space-y-2">
                <label className="text-sm font-bold text-slate-700">Pemateri</label>
                <input
                  required
                  type="text"
                  value={formData.pemateri}
                  onChange={(e) => setFormData({...formData, pemateri: e.target.value})}
                  className="w-full p-3 rounded-lg border border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
                  placeholder="Nama Pemateri"
                />
              </div>
              <div className="space-y-2">
                <label className="text-sm font-bold text-slate-700">Tanggal</label>
                <input
                  required
                  type="date"
                  value={formData.tanggal}
                  onChange={(e) => setFormData({...formData, tanggal: e.target.value})}
                  className="w-full p-3 rounded-lg border border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
                />
              </div>
              <div className="space-y-2">
                <label className="text-sm font-bold text-slate-700">Kuota</label>
                <input
                  required
                  type="number"
                  value={formData.kuota}
                  onChange={(e) => setFormData({...formData, kuota: e.target.value})}
                  className="w-full p-3 rounded-lg border border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
                  placeholder="0"
                />
              </div>
            </div>
           
            <button
              disabled={isSubmitting}
              type="submit"
              className="w-full md:w-auto px-8 py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-all disabled:opacity-50 flex items-center justify-center space-x-2"
            >
              {isSubmitting ? <Loader2 className="animate-spin" /> : <PlusCircle size={20} />}
              <span>SIMPAN PELATIHAN</span>
            </button>
          </form>
        ) : (
          <PesertaManager pelatihan={pelatihan} peserta={peserta} onRefresh={onRefresh} />
        )}
      </div>
    </div>
  );
};

const PesertaManager = ({ pelatihan, peserta, onRefresh }) => {
  const [selectedPelatihan, setSelectedPelatihan] = useState('');
  const [formPeserta, setFormPeserta] = useState({ nama: '', email: '', institusi: '', foto: null });
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleFileChange = (e) => {
    const file = e.target.files[0];
    const reader = new FileReader();
    reader.onloadend = () => {
      setFormPeserta({ ...formPeserta, foto: reader.result, fotoName: file.name });
    };
    if (file) reader.readAsDataURL(file);
  };

  const handleAddPeserta = async (e) => {
    e.preventDefault();
    if (!selectedPelatihan) return;
    setIsSubmitting(true);
   
    try {
      await fetch(API_URL, {
        method: 'POST',
        mode: 'no-cors',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          action: 'addPeserta',
          idPelatihan: selectedPelatihan,
          fotoBase64: formPeserta.foto,
          fotoName: formPeserta.fotoName,
          ...formPeserta
        })
      });
      setFormPeserta({ nama: '', email: '', institusi: '', foto: null });
      setTimeout(() => onRefresh(), 2000);
    } catch (err) {
      console.error(err);
    } finally {
      setIsSubmitting(false);
    }
  };

  const filteredPeserta = selectedPelatihan
    ? peserta.filter(p => p['ID Pelatihan'] === selectedPelatihan)
    : [];

  return (
    <div className="space-y-8">
      <div className="bg-slate-50 p-6 rounded-xl border border-slate-200">
        <h4 className="font-bold mb-4 flex items-center space-x-2">
          <PlusCircle size={18} className="text-blue-600" />
          <span>Tambah Peserta Baru</span>
        </h4>
        <form onSubmit={handleAddPeserta} className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <select
            required
            className="p-3 rounded-lg border border-slate-300 outline-none"
            value={selectedPelatihan}
            onChange={(e) => setSelectedPelatihan(e.target.value)}
          >
            <option value="">Pilih Pelatihan</option>
            {pelatihan.map(p => <option key={p.ID} value={p.ID}>{p['Nama Pelatihan']}</option>)}
          </select>
          <input required type="text" placeholder="Nama Peserta" value={formPeserta.nama} onChange={e => setFormPeserta({...formPeserta, nama: e.target.value})} className="p-3 rounded-lg border border-slate-300" />
          <input required type="email" placeholder="Email" value={formPeserta.email} onChange={e => setFormPeserta({...formPeserta, email: e.target.value})} className="p-3 rounded-lg border border-slate-300" />
          <input required type="text" placeholder="Institusi" value={formPeserta.institusi} onChange={e => setFormPeserta({...formPeserta, institusi: e.target.value})} className="p-3 rounded-lg border border-slate-300" />
          <div className="md:col-span-2 flex items-center space-x-4">
            <label className="flex-1 cursor-pointer bg-white border border-dashed border-slate-400 p-3 rounded-lg text-center hover:bg-slate-50">
              <div className="flex items-center justify-center space-x-2 text-slate-500">
                <Camera size={18} />
                <span>{formPeserta.fotoName || 'Klik untuk Upload Foto'}</span>
              </div>
              <input type="file" className="hidden" accept="image/*" onChange={handleFileChange} />
            </label>
            <button disabled={isSubmitting} className="px-8 py-3 bg-blue-600 text-white rounded-lg font-bold disabled:opacity-50 min-w-[150px]">
              {isSubmitting ? 'Mengirim...' : 'Tambah'}
            </button>
          </div>
        </form>
      </div>

      <div>
        <h4 className="font-bold mb-4">Daftar Peserta Pelatihan</h4>
        {selectedPelatihan ? (
          <div className="overflow-x-auto">
            <table className="w-full text-left border-collapse">
              <thead>
                <tr className="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
                  <th className="p-4 border-b">Foto</th>
                  <th className="p-4 border-b">Nama</th>
                  <th className="p-4 border-b">Email</th>
                  <th className="p-4 border-b">Institusi</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {filteredPeserta.length > 0 ? filteredPeserta.map((p, i) => (
                  <tr key={i} className="hover:bg-slate-50">
                    <td className="p-4">
                      {p.Foto ? (
                        <img src={p.Foto} alt="Avatar" className="w-10 h-10 rounded-full object-cover border border-slate-200" />
                      ) : (
                        <div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center">
                          <Users size={16} className="text-slate-400" />
                        </div>
                      )}
                    </td>
                    <td className="p-4 font-medium text-slate-800">{p['Nama Peserta']}</td>
                    <td className="p-4 text-slate-600">{p.Email}</td>
                    <td className="p-4 text-slate-600">{p.Institusi}</td>
                  </tr>
                )) : (
                  <tr><td colSpan="4" className="p-10 text-center text-slate-400">Belum ada peserta yang terdaftar</td></tr>
                )}
              </tbody>
            </table>
          </div>
        ) : (
          <div className="p-10 border border-dashed border-slate-200 rounded-xl text-center text-slate-400">
            Pilih pelatihan di atas untuk melihat daftar peserta
          </div>
        )}
      </div>
    </div>
  );
};

export default App;

Build dengan Vite

1. Jalankan Perintah Build

Buka terminal di VS Code (pada folder project Anda), lalu ketik perintah berikut:

Bash
npm run build

2. Apa yang Terjadi Setelahnya?

  • Vite akan memproses semua file JSX, CSS, dan aset lainnya.

  • Vite akan melakukan minification (memperkecil ukuran file) agar aplikasi lebih cepat dimuat.

  • Folder baru bernama dist akan muncul di dalam direktori project Anda.

3. Masalah Umum: Path Kosong (PENTING)

Secara default, Vite mengasumsikan aplikasi akan diletakkan di root domain (contoh: [https://situsanda.com/](https://situsanda.com/)). Jika Anda ingin meng-embed hasil build ini ke Google Sites atau GitHub Pages (yang biasanya memiliki sub-path), file seringkali tidak muncul (blank) karena path CSS/JS salah.

Cara Memperbaikinya:

  1. Buka file vite.config.js di project Anda.

  2. Tambahkan properti base: './', agar semua link file menjadi relatif.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  base: './', // Tambahkan baris ini agar folder dist bisa dibuka langsung
})
```
3.  Simpan file tersebut, lalu jalankan `npm run build` kembali.

### 4. Cara Tes Hasil Build (Preview)
Anda tidak bisa langsung membuka file `index.html` di dalam folder `dist` hanya dengan klik kanan > open browser (seringkali akan error karena modul JS). Vite menyediakan cara untuk mengetesnya:

```bash
npm run preview
```
Perintah ini akan menjalankan server lokal khusus untuk melihat bagaimana tampilan aplikasi Anda setelah di-build.

### 5. Mengirim ke Hosting (Netlify/Google Sites)
*   **Netlify:** Anda cukup melakukan *drag-and-drop* folder **`dist`** tersebut ke area upload Netlify Drop.
*   **Google Sites:** Gunakan URL yang didapat dari Netlify tersebut untuk di-embed melalui opsi **Embed > By URL**.

Sudahkah folder `dist` muncul di sidebar VS Code Anda setelah menjalankan perintah tersebut?

Tidak ada komentar:

Posting Komentar

React - Latihan 2 Hosting GitHub

  https://www.youtube.com/watch?v=hn1IkJk24ow&t=185s Hosting di GitHub (melalui GitHub Pages ) adalah cara gratis dan populer untuk memp...