Buatkan aplikasi Studi Kasus (Final UI) Aplikasi Upload Foto
✔ SPA
✔ CRUD Lengkap
✔ Bootstrap 5
✔ Hero Section
✔ Responsive
BackEnd - Code.gs
// ================== KONFIGURASI ==================
const SPREADSHEET_ID = "PASTE_SPREADSHEET_ID_DI_SINI";
const SHEET_NAME = "DataSiswa";
const FOLDER_ID = "PASTE_FOLDER_ID_DI_SINI";
// ================== WEB APP ENTRY ==================
function doGet() {
return HtmlService.createHtmlOutputFromFile("index")
.setTitle("SISKO Photo CRUD")
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag("viewport", "width=device-width, initial-scale=1.0");
}
// ================== UTIL ==================
function getSheet_() {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
let sh = ss.getSheetByName(SHEET_NAME);
if (!sh) {
sh = ss.insertSheet(SHEET_NAME);
sh.appendRow(["id", "timestamp", "nama", "nisn", "kelas", "jurusan", "alamat", "fotoUrl"]);
sh.getRange(1, 1, 1, 8).setFontWeight("bold");
}
return sh;
}
function uuid_() {
// ID sederhana & unik (cukup untuk sheet)
return "ID-" + Date.now() + "-" + Math.floor(Math.random() * 1e6);
}
function normalizeUrl_(url) {
if (!url) return "";
const s = String(url);
// format lama: drive.google.com/open?id=...
if (s.includes("drive.google.com") && s.includes("id=")) {
try {
const id = s.split("id=")[1].split("&")[0];
return "https://lh3.googleusercontent.com/d/" + id;
} catch (e) {
return s;
}
}
return s;
}
// ================== UPLOAD FOTO ==================
function uploadImage(base64, filename, mimeType) {
try {
const base64Data = String(base64).split(",")[1]; // ambil data murni
const bytes = Utilities.base64Decode(base64Data);
const finalMime = mimeType || "image/jpeg";
const safeName = filename || ("foto_" + Date.now());
const blob = Utilities.newBlob(bytes, finalMime, safeName);
const folder = DriveApp.getFolderById(FOLDER_ID);
const file = folder.createFile(blob);
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
// URL direct render (anti-block)
const directUrl = "https://lh3.googleusercontent.com/d/" + file.getId();
return { success: true, url: directUrl, fileId: file.getId() };
} catch (error) {
return { success: false, message: "Error upload: " + error.toString() };
}
}
// ================== CRUD ==================
function createStudent(payload) {
try {
const sh = getSheet_();
const id = uuid_();
const now = new Date();
const row = [
id,
now,
payload.nama || "",
payload.nisn || "",
payload.kelas || "",
payload.jurusan || "",
payload.alamat || "",
normalizeUrl_(payload.fotoUrl || "")
];
sh.appendRow(row);
return { success: true, message: "Data berhasil ditambahkan", id: id };
} catch (e) {
return { success: false, message: e.toString() };
}
}
function listStudents() {
try {
const sh = getSheet_();
const values = sh.getDataRange().getValues();
if (values.length <= 1) return { success: true, data: [] };
const headers = values[0];
const data = values.slice(1).map(r => {
const obj = {};
headers.forEach((h, i) => (obj[h] = r[i]));
obj.timestamp = obj.timestamp ? new Date(obj.timestamp).toISOString() : "";
obj.fotoUrl = normalizeUrl_(obj.fotoUrl);
return obj;
});
// urut terbaru
data.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
return { success: true, data };
} catch (e) {
return { success: false, message: e.toString(), data: [] };
}
}
function getStudentById(id) {
const sh = getSheet_();
const values = sh.getDataRange().getValues();
if (values.length <= 1) return { success: false, message: "Data kosong" };
const headers = values[0];
for (let i = 1; i < values.length; i++) {
if (String(values[i][0]) === String(id)) {
const obj = {};
headers.forEach((h, idx) => (obj[h] = values[i][idx]));
obj.timestamp = obj.timestamp ? new Date(obj.timestamp).toISOString() : "";
obj.fotoUrl = normalizeUrl_(obj.fotoUrl);
return { success: true, data: obj };
}
}
return { success: false, message: "ID tidak ditemukan" };
}
function updateStudent(id, payload) {
try {
const sh = getSheet_();
const values = sh.getDataRange().getValues();
if (values.length <= 1) return { success: false, message: "Data kosong" };
// kolom: id, timestamp, nama, nisn, kelas, jurusan, alamat, fotoUrl
for (let i = 1; i < values.length; i++) {
if (String(values[i][0]) === String(id)) {
const rowIndex = i + 1; // 1-based
const updated = [
id,
new Date(), // update timestamp
payload.nama || "",
payload.nisn || "",
payload.kelas || "",
payload.jurusan || "",
payload.alamat || "",
normalizeUrl_(payload.fotoUrl || "")
];
sh.getRange(rowIndex, 1, 1, 8).setValues([updated]);
return { success: true, message: "Data berhasil diupdate" };
}
}
return { success: false, message: "ID tidak ditemukan" };
} catch (e) {
return { success: false, message: e.toString() };
}
}
function deleteStudent(id) {
try {
const sh = getSheet_();
const values = sh.getDataRange().getValues();
if (values.length <= 1) return { success: false, message: "Data kosong" };
for (let i = 1; i < values.length; i++) {
if (String(values[i][0]) === String(id)) {
sh.deleteRow(i + 1);
return { success: true, message: "Data berhasil dihapus" };
}
}
return { success: false, message: "ID tidak ditemukan" };
} catch (e) {
return { success: false, message: e.toString() };
}
}
FrontEnd - Index.html
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SISKO Photo CRUD</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root{
--brand:#dc3545; /* merah */
--ink:#0b1220;
--muted:#6c757d;
--card:#ffffff;
--bg:#f6f7fb;
}
body{ background:var(--bg); color:var(--ink); }
.navbar{ backdrop-filter:saturate(180%) blur(10px); }
.hero{
background: radial-gradient(1200px 600px at 10% 10%, rgba(220,53,69,.18), transparent 60%),
radial-gradient(900px 500px at 90% 20%, rgba(13,110,253,.12), transparent 55%),
linear-gradient(180deg, #fff, #f7f8ff);
border-bottom: 1px solid rgba(0,0,0,.06);
}
.hero-badge{ background:rgba(220,53,69,.1); color:var(--brand); border:1px solid rgba(220,53,69,.25); }
.card{ border:1px solid rgba(0,0,0,.08); box-shadow: 0 10px 25px rgba(0,0,0,.04); }
.thumb{
width: 52px; height: 52px; border-radius: 12px;
object-fit: cover; border:1px solid rgba(0,0,0,.12);
background:#fff;
}
.route{ display:none; }
.route.active{ display:block; }
.btn-brand{ background:var(--brand); border-color:var(--brand); }
.btn-brand:hover{ filter:brightness(.95); }
.skeleton{
background: linear-gradient(90deg, #eee, #f5f5f5, #eee);
background-size: 200% 100%;
animation: sh 1.2s infinite;
border-radius: 12px;
height: 64px;
}
@keyframes sh { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
.toast-container{ z-index: 9999; }
</style>
</head>
<body>
<!-- NAV -->
<nav class="navbar navbar-expand-lg bg-white border-bottom sticky-top">
<div class="container py-1">
<a class="navbar-brand fw-bold" href="#/">
<span class="badge rounded-pill me-2" style="background:var(--brand)">SISKO</span>
Photo CRUD
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
<span class="navbar-toggler-icon"></span>
</button>
<div id="nav" class="collapse navbar-collapse">
<ul class="navbar-nav ms-auto gap-lg-2">
<li class="nav-item"><a class="nav-link" href="#/">Home</a></li>
<li class="nav-item"><a class="nav-link" href="#/form">Form</a></li>
<li class="nav-item"><a class="nav-link" href="#/data">Data</a></li>
</ul>
</div>
</div>
</nav>
<!-- TOAST -->
<div class="toast-container position-fixed top-0 end-0 p-3"></div>
<!-- HERO / HOME -->
<section id="route-home" class="route active hero">
<div class="container py-5">
<div class="row align-items-center g-4">
<div class="col-lg-6">
<div class="d-inline-flex align-items-center gap-2 px-3 py-2 rounded-pill hero-badge mb-3">
<span class="fw-semibold">✔ SPA</span>
<span class="text-muted">•</span>
<span class="fw-semibold">✔ CRUD</span>
<span class="text-muted">•</span>
<span class="fw-semibold">✔ Upload Foto</span>
</div>
<h1 class="display-6 fw-bold lh-sm">
Aplikasi Upload Foto Siswa<br class="d-none d-md-block">
dengan <span class="text-danger">UI Final</span> & Responsive
</h1>
<p class="mt-3 text-secondary">
Studi kasus untuk sistem informasi sekolah: simpan data siswa ke Google Sheet
dan upload foto ke Google Drive (langsung tampil di web).
</p>
<div class="d-flex gap-2 flex-wrap mt-4">
<a class="btn btn-brand text-white px-4" href="#/form">Mulai Input</a>
<a class="btn btn-outline-secondary px-4" href="#/data">Lihat Data</a>
</div>
<div class="mt-4 small text-secondary">
Tips: gunakan foto ukuran kecil agar upload lebih cepat.
</div>
</div>
<div class="col-lg-6">
<div class="card p-4">
<div class="d-flex align-items-start gap-3">
<div class="rounded-4 d-flex align-items-center justify-content-center"
style="width:56px;height:56px;background:rgba(220,53,69,.1);">
<span class="fw-bold text-danger">UI</span>
</div>
<div>
<div class="fw-bold">Fitur Utama</div>
<div class="text-secondary small">Upload → simpan → tampil → edit → hapus</div>
</div>
</div>
<hr class="my-4">
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="p-3 border rounded-4 bg-light">
<div class="fw-semibold">Drive Upload</div>
<div class="small text-secondary">Foto jadi URL direct render</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="p-3 border rounded-4 bg-light">
<div class="fw-semibold">Google Sheet DB</div>
<div class="small text-secondary">CRUD lengkap</div>
</div>
</div>
<div class="col-12">
<div class="p-3 border rounded-4 bg-light">
<div class="fw-semibold">SPA Routing</div>
<div class="small text-secondary">Tanpa reload halaman (#/home, #/form, #/data)</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- FORM -->
<section id="route-form" class="route">
<div class="container py-4 py-lg-5">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 class="h4 fw-bold mb-1">Form Siswa</h2>
<div class="text-secondary small">Tambah data / edit data + upload foto</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" id="btnReset">Reset</button>
<a class="btn btn-outline-secondary" href="#/data">Ke Data</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="card p-4">
<form id="studentForm" class="row g-3">
<input type="hidden" id="id">
<div class="col-md-6">
<label class="form-label fw-semibold">Nama</label>
<input class="form-control" id="nama" required placeholder="Contoh: Budi Santoso">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">NISN</label>
<input class="form-control" id="nisn" required placeholder="Contoh: 2024001">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kelas</label>
<input class="form-control" id="kelas" placeholder="X / XI / XII">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Jurusan</label>
<input class="form-control" id="jurusan" placeholder="IPA / IPS / TKJ ...">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Foto</label>
<input class="form-control" id="foto" type="file" accept="image/*">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Alamat</label>
<textarea class="form-control" id="alamat" rows="2" placeholder="Alamat lengkap..."></textarea>
</div>
<div class="col-12">
<div class="d-flex align-items-center gap-3 flex-wrap">
<button class="btn btn-brand text-white px-4" type="submit" id="btnSubmit">
Simpan
</button>
<div class="text-secondary small" id="uploadHint">
Jika foto dipilih, sistem akan upload dulu lalu simpan URL-nya.
</div>
</div>
</div>
</form>
</div>
<div class="text-secondary small mt-3">
Catatan: upload foto akan dibuat “public link” (anyone with link).
</div>
</div>
<div class="col-lg-5">
<div class="card p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="fw-bold">Preview</div>
<span class="badge text-bg-light border">Live</span>
</div>
<div class="d-flex align-items-center gap-3">
<img id="previewImg" class="thumb" alt="preview"
src="https://via.placeholder.com/96x96.png?text=Foto">
<div>
<div class="fw-semibold" id="previewName">Nama Siswa</div>
<div class="text-secondary small" id="previewMeta">NISN • Kelas • Jurusan</div>
</div>
</div>
<hr class="my-4">
<div class="small text-secondary">
Tips kualitas UI: pakai foto square (1:1) agar rapi di tabel.
</div>
</div>
</div>
</div>
</div>
</section>
<!-- DATA -->
<section id="route-data" class="route">
<div class="container py-4 py-lg-5">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 class="h4 fw-bold mb-1">Data Siswa</h2>
<div class="text-secondary small">Search, edit, delete (CRUD lengkap)</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<div class="input-group">
<span class="input-group-text">🔎</span>
<input id="q" class="form-control" placeholder="Cari nama / NISN / kelas...">
</div>
<button class="btn btn-outline-secondary" id="btnRefresh">Refresh</button>
<a class="btn btn-brand text-white" href="#/form">Tambah</a>
</div>
</div>
<div class="card p-3 p-lg-4">
<div id="loadingList" class="d-grid gap-2">
<div class="skeleton"></div>
<div class="skeleton"></div>
<div class="skeleton"></div>
</div>
<div class="table-responsive" id="tableWrap" style="display:none">
<table class="table align-middle mb-0">
<thead>
<tr class="text-secondary small">
<th>Foto</th>
<th>Nama</th>
<th>NISN</th>
<th>Kelas</th>
<th>Jurusan</th>
<th>Alamat</th>
<th class="text-end">Aksi</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="emptyState" class="text-center text-secondary py-5" style="display:none">
Belum ada data. Klik <b>Tambah</b> untuk input siswa.
</div>
</div>
</div>
</section>
<!-- Bootstrap -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// =============== STATE ===============
let ALL = [];
let CURRENT_PHOTO_URL = ""; // url foto yang sudah diupload / existing
// =============== ROUTER ===============
const routes = {
"/": "route-home",
"/form": "route-form",
"/data": "route-data"
};
function setActiveRoute(path){
const id = routes[path] || routes["/"];
document.querySelectorAll(".route").forEach(el => el.classList.remove("active"));
document.getElementById(id).classList.add("active");
// page hooks
if (path === "/data") loadList();
if (path === "/form") syncPreview();
}
function parseHash(){
const raw = location.hash.replace("#", "") || "/";
// format: /form?id=...
const [path, qs] = raw.split("?");
const params = new URLSearchParams(qs || "");
return { path, params };
}
window.addEventListener("hashchange", () => {
const { path, params } = parseHash();
setActiveRoute(path);
// handle edit from query string
const id = params.get("id");
if (path === "/form" && id) loadToForm(id);
});
// =============== TOAST ===============
function toast(msg, type="success"){
const wrap = document.querySelector(".toast-container");
const el = document.createElement("div");
el.className = `toast align-items-center text-bg-${type} border-0`;
el.role = "alert";
el.innerHTML = `
<div class="d-flex">
<div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>`;
wrap.appendChild(el);
const t = new bootstrap.Toast(el, { delay: 2500 });
t.show();
el.addEventListener("hidden.bs.toast", () => el.remove());
}
// =============== HELPERS ===============
function byId(id){ return document.getElementById(id); }
function setLoading(isLoading){
byId("loadingList").style.display = isLoading ? "grid" : "none";
byId("tableWrap").style.display = isLoading ? "none" : "block";
}
function escapeHtml(s){
return String(s ?? "")
.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">")
.replaceAll('"',""").replaceAll("'","'");
}
// =============== PREVIEW ===============
function syncPreview(){
const nama = byId("nama").value || "Nama Siswa";
const nisn = byId("nisn").value || "NISN";
const kelas = byId("kelas").value || "Kelas";
const jurusan = byId("jurusan").value || "Jurusan";
byId("previewName").textContent = nama;
byId("previewMeta").textContent = `${nisn} • ${kelas} • ${jurusan}`;
const file = byId("foto").files[0];
if (file){
const url = URL.createObjectURL(file);
byId("previewImg").src = url;
} else if (CURRENT_PHOTO_URL){
byId("previewImg").src = CURRENT_PHOTO_URL;
} else {
byId("previewImg").src = "https://via.placeholder.com/96x96.png?text=Foto";
}
}
["nama","nisn","kelas","jurusan","alamat"].forEach(id => {
document.addEventListener("input", (e) => {
if (e.target && e.target.id === id) syncPreview();
});
});
byId("foto").addEventListener("change", syncPreview);
// =============== CRUD CALLS ===============
function loadList(){
setLoading(true);
byId("emptyState").style.display = "none";
google.script.run
.withSuccessHandler(res => {
setLoading(false);
if (!res.success){ toast(res.message || "Gagal load data", "danger"); return; }
ALL = res.data || [];
renderTable();
})
.withFailureHandler(err => {
setLoading(false);
toast("Error: " + err.message, "danger");
})
.listStudents();
}
function renderTable(){
const q = (byId("q").value || "").toLowerCase().trim();
const filtered = ALL.filter(x => {
const hay = `${x.nama} ${x.nisn} ${x.kelas} ${x.jurusan} ${x.alamat}`.toLowerCase();
return !q || hay.includes(q);
});
const tb = byId("tbody");
tb.innerHTML = "";
if (!filtered.length){
byId("tableWrap").style.display = "none";
byId("emptyState").style.display = "block";
return;
} else {
byId("tableWrap").style.display = "block";
byId("emptyState").style.display = "none";
}
filtered.forEach(item => {
const tr = document.createElement("tr");
const foto = item.fotoUrl ? escapeHtml(item.fotoUrl) : "https://via.placeholder.com/96x96.png?text=Foto";
tr.innerHTML = `
<td><img class="thumb" src="${foto}" alt="foto"></td>
<td class="fw-semibold">${escapeHtml(item.nama)}</td>
<td>${escapeHtml(item.nisn)}</td>
<td>${escapeHtml(item.kelas)}</td>
<td>${escapeHtml(item.jurusan)}</td>
<td class="text-secondary small" style="max-width:260px">${escapeHtml(item.alamat)}</td>
<td class="text-end">
<div class="btn-group">
<a class="btn btn-sm btn-outline-primary" href="#/form?id=${encodeURIComponent(item.id)}">Edit</a>
<button class="btn btn-sm btn-outline-danger" data-del="${escapeHtml(item.id)}">Hapus</button>
</div>
</td>
`;
tb.appendChild(tr);
});
// bind delete
tb.querySelectorAll("[data-del]").forEach(btn => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-del");
if (!confirm("Hapus data ini?")) return;
google.script.run
.withSuccessHandler(res => {
if (!res.success){ toast(res.message || "Gagal hapus", "danger"); return; }
toast("Berhasil dihapus");
loadList();
})
.withFailureHandler(err => toast("Error: " + err.message, "danger"))
.deleteStudent(id);
});
});
}
byId("q").addEventListener("input", renderTable);
byId("btnRefresh").addEventListener("click", loadList);
// =============== FORM: LOAD EDIT ===============
function loadToForm(id){
resetForm(false);
google.script.run
.withSuccessHandler(res => {
if (!res.success){ toast(res.message || "ID tidak ditemukan", "danger"); return; }
const d = res.data;
byId("id").value = d.id || "";
byId("nama").value = d.nama || "";
byId("nisn").value = d.nisn || "";
byId("kelas").value = d.kelas || "";
byId("jurusan").value = d.jurusan || "";
byId("alamat").value = d.alamat || "";
CURRENT_PHOTO_URL = d.fotoUrl || "";
byId("btnSubmit").textContent = "Update";
toast("Mode edit aktif", "primary");
syncPreview();
})
.withFailureHandler(err => toast("Error: " + err.message, "danger"))
.getStudentById(id);
}
// =============== FORM: SUBMIT ===============
function fileToBase64(file){
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function handleSubmit(e){
e.preventDefault();
const payload = {
nama: byId("nama").value.trim(),
nisn: byId("nisn").value.trim(),
kelas: byId("kelas").value.trim(),
jurusan: byId("jurusan").value.trim(),
alamat: byId("alamat").value.trim(),
fotoUrl: CURRENT_PHOTO_URL || ""
};
const id = byId("id").value;
const file = byId("foto").files[0];
byId("btnSubmit").disabled = true;
byId("btnSubmit").textContent = "Memproses...";
try {
// 1) jika ada foto baru → upload dulu
if (file){
const base64 = await fileToBase64(file);
const up = await new Promise((resolve, reject) => {
google.script.run
.withSuccessHandler(resolve)
.withFailureHandler(reject)
.uploadImage(base64, file.name, file.type);
});
if (!up.success) throw new Error(up.message || "Upload gagal");
payload.fotoUrl = up.url;
CURRENT_PHOTO_URL = up.url;
}
// 2) create / update
const res = await new Promise((resolve, reject) => {
const runner = google.script.run.withSuccessHandler(resolve).withFailureHandler(reject);
if (id) runner.updateStudent(id, payload);
else runner.createStudent(payload);
});
if (!res.success) throw new Error(res.message || "Gagal simpan");
toast(id ? "Berhasil diupdate" : "Berhasil ditambahkan");
resetForm(true);
location.hash = "#/data";
} catch (err) {
toast(err.message || "Terjadi error", "danger");
} finally {
byId("btnSubmit").disabled = false;
byId("btnSubmit").textContent = (byId("id").value ? "Update" : "Simpan");
}
}
byId("studentForm").addEventListener("submit", handleSubmit);
// =============== RESET FORM ===============
function resetForm(showToast=true){
byId("id").value = "";
byId("nama").value = "";
byId("nisn").value = "";
byId("kelas").value = "";
byId("jurusan").value = "";
byId("alamat").value = "";
byId("foto").value = "";
CURRENT_PHOTO_URL = "";
byId("btnSubmit").textContent = "Simpan";
if (showToast) toast("Form direset", "secondary");
syncPreview();
}
byId("btnReset").addEventListener("click", () => resetForm(true));
// =============== INIT ===============
(function init(){
const { path, params } = parseHash();
setActiveRoute(path);
const id = params.get("id");
if (path === "/form" && id) loadToForm(id);
if (!location.hash) location.hash = "#/";
})();
</script>
</body>
</html>



Tidak ada komentar:
Posting Komentar