Lompat ke konten Lompat ke sidebar Lompat ke footer

Source Code Arsip Penyimpanan Digital

Source Code Aplikasi Arsip Digital Pribadi

Berbasis Google Sheets & Google Drive (Gratis & Unlimited)

Di era serba digital saat ini, menyimpan dokumen penting seperti Ijazah, SK, Sertifikat, hingga Foto kenangan dalam bentuk fisik memiliki risiko tinggi (hilang, rusak, atau sulit dicari). Solusi terbaik adalah memiliki Arsip Digital Pribadi yang bisa diakses kapan saja dan di mana saja melalui HP atau Laptop.

Pada postingan kali ini, saya akan membagikan Source Code lengkap untuk membuat Web App Arsip Digital yang Premium, Responsif, dan Gratis karena menggunakan server Google (Google Apps Script).

🚀 Fitur & Manfaat

Tanpa Hosting & Domain: 100% Gratis menggunakan akun Google Anda.
Penyimpanan Luas: Menggunakan Google Drive (15GB gratis).
Upload File Besar: Mendukung file >50MB dengan sistem Chunked Upload.
Multi-Kategori: Klasifikasi dokumen (Pribadi, Pendidikan, SKP, dll).
Mobile Friendly: Tampilan seperti aplikasi native di Android/iOS.
Pencarian Cepat: Cari dokumen dalam hitungan detik.

🛠️ Persiapan Database

Sebelum menyalin kode, siapkan database Anda terlebih dahulu:

  1. Buka Google Sheets baru.
  2. Ubah nama Sheet (Tab bawah) menjadi: Data.
  3. Buat Header di baris pertama (A1 sampai H1) dengan urutan:
    • A1: ID
    • B1: Nama Dokumen
    • C1: Kategori
    • D1: Tipe
    • E1: Ukuran
    • F1: Tanggal
    • G1: URL File
    • H1: Drive ID
  4. Buat Folder baru di Google Drive untuk menyimpan file, lalu Salin ID Folder tersebut (ID ada di bagian akhir URL folder).

💻 Langkah Instalasi

Langkah 1: Buka menu Ekstensi > Apps Script di Google Sheets Anda.
Langkah 2: Salin kode di bawah ini ke file Code.gs.
⚠️ PENTING: Pada baris ke-3, ganti tulisan MASUKKAN ID FOLDER DRIVE DISINI dengan ID Folder Google Drive milik Anda sendiri agar file masuk ke Drive Anda.
Code.gs
// --- KONFIGURASI ---
// Ganti dengan ID Folder Drive Anda sendiri
const FOLDER_ID = "MASUKKAN ID FOLDER DRIVE DISINI"; 
const SHEET_NAME = "Data";

function doGet() {
  return HtmlService.createTemplateFromFile('Index')
      .evaluate()
      .setTitle('Arsip Dokumen Pribadi')
      .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
      .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

// --- FUNGSI 1: AMBIL DATA ---
function getDocuments() {
  const lock = LockService.getScriptLock();
  if (lock.tryLock(10000)) {
    try {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
      if (!sheet) return [];
      
      const lastRow = sheet.getLastRow();
      if (lastRow < 2) return [];

      const data = sheet.getRange(2, 1, lastRow - 1, 8).getValues();
      return data.map(row => ({
        id: row[0],
        name: row[1],
        category: row[2],
        type: row[3],
        size: row[4],
        date: Utilities.formatDate(new Date(row[5]), Session.getScriptTimeZone(), "dd MMM yyyy"),
        url: row[6],
        driveId: row[7]
      })).reverse(); 
    } catch (e) {
      return []; 
    } finally {
      lock.releaseLock();
    }
  }
  return [];
}

// --- FUNGSI 2: UPLOAD BERTAHAP (CHUNKED) ---
function processChunk(e) {
  try {
    const folder = DriveApp.getFolderById(FOLDER_ID);
    
    // A. MULAI UPLOAD
    if (e.type === 'start') {
      const tempFile = folder.createFile("TEMP_" + Utilities.getUuid(), "", MimeType.PLAIN_TEXT);
      return { status: 'success', tempId: tempFile.getId() };
    }

    // B. APPEND DATA
    if (e.type === 'append') {
      const tempFile = DriveApp.getFileById(e.tempId);
      const text = tempFile.getBlob().getDataAsString();
      tempFile.setContent(text + e.data);
      return { status: 'success' };
    }

    // C. FINISHING
    if (e.type === 'finish') {
      const tempFile = DriveApp.getFileById(e.tempId);
      const base64String = tempFile.getBlob().getDataAsString();
      
      const decoded = Utilities.base64Decode(base64String);
      const blob = Utilities.newBlob(decoded, e.mimeType, e.name);
      
      const finalFile = folder.createFile(blob);
      finalFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
      
      tempFile.setTrashed(true);

      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
      
      const sizeBytes = finalFile.getSize();
      let sizeStr = "";
      if (sizeBytes < 1024 * 1024) {
        sizeStr = (sizeBytes / 1024).toFixed(0) + " KB";
      } else {
        sizeStr = (sizeBytes / (1024 * 1024)).toFixed(2) + " MB";
      }

      sheet.appendRow([
        Utilities.getUuid(),
        e.customName || e.name,
        e.category,
        e.mimeType,
        sizeStr,
        new Date(),
        finalFile.getDownloadUrl(),
        finalFile.getId()
      ]);

      return { status: 'success' };
    }

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

// --- FUNGSI 3: HAPUS ---
function deleteDocument(docId, driveId) {
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const data = sheet.getDataRange().getValues();
    
    for (let i = 1; i < data.length; i++) {
      if (data[i][0] == docId) {
        sheet.deleteRow(i + 1);
        try {
          DriveApp.getFileById(driveId).setTrashed(true);
        } catch (err) { }
        return { success: true };
      }
    }
    return { success: false, message: "Data tidak ditemukan." };
  } catch (e) {
    return { success: false, message: e.toString() };
  }
}
Langkah 3: Buat file HTML baru dengan nama Index.html, lalu salin kode Tampilan (Frontend) berikut ini.
Index.html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <title>Arsip Dokumen Pribadi</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  
  <!-- Framework CSS -->
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  
  <style>
    body { font-family: 'Inter', sans-serif; background-color: #f8fafc; color: #334155; }
    
    /* Sidebar Styling */
    .sidebar {
      width: 280px; height: 100vh; position: fixed;
      background: white; border-right: 1px solid #e2e8f0;
      padding: 24px; display: flex; flex-direction: column;
      z-index: 1000; transition: transform 0.3s ease;
    }
    .menu-item {
      display: flex; align-items: center; gap: 12px;
      padding: 12px 16px; border-radius: 8px;
      color: #64748b; text-decoration: none; font-weight: 500;
      margin-bottom: 4px; transition: all 0.2s; cursor: pointer;
    }
    .menu-item:hover, .menu-item.active { background: #eff6ff; color: #2563eb; }
    
    /* Content Area */
    .main { margin-left: 280px; padding: 32px; min-height: 100vh; }
    
    /* Card Design */
    .doc-card {
      background: white; border-radius: 12px;
      border: 1px solid #e2e8f0; overflow: hidden;
      transition: all 0.2s; height: 100%;
    }
    .doc-card:hover { transform: translateY(-4px); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); }
    .card-thumb {
      height: 140px; background: #f1f5f9;
      display: flex; align-items: center; justify-content: center;
      position: relative; cursor: pointer;
    }
    .card-thumb img { width: 100%; height: 100%; object-fit: cover; }
    
    /* Loader & Responsive */
    #pageLoader {
      position: fixed; inset: 0; background: rgba(255,255,255,0.9);
      display: flex; flex-direction: column; align-items: center; justify-content: center;
      z-index: 2000;
    }

    @media (max-width: 992px) {
      .sidebar { transform: translateX(-100%); }
      .sidebar.open { transform: translateX(0); }
      .main { margin-left: 0; padding: 16px; }
      .overlay {
        position: fixed; inset: 0; background: rgba(0,0,0,0.5);
        z-index: 999; display: none;
      }
      .overlay.show { display: block; }
    }
  </style>
</head>
<body>

  <!-- Loading Screen -->
  <div id="pageLoader">
    <div class="spinner-border text-primary" role="status"></div>
    <p class="mt-3 fw-medium text-muted">Sinkronisasi Data...</p>
  </div>

  <div class="overlay" onclick="toggleSidebar()"></div>

  <!-- Sidebar Menu -->
  <aside class="sidebar" id="sidebar">
    <div class="d-flex align-items-center gap-3 mb-5">
      <div class="bg-primary text-white rounded-lg p-2 d-flex align-items-center justify-content-center shadow-sm">
        <i class="bi bi-archive-fill fs-5"></i>
      </div>
      <div>
        <h5 class="fw-bold mb-0 text-dark">ArsipKu</h5>
        <small class="text-muted">Penyimpanan Digital</small>
      </div>
    </div>

    <div class="flex-grow-1">
      <small class="text-uppercase text-muted fw-bold ps-3" style="font-size: 11px; letter-spacing: 1px;">Kategori</small>
      <div class="mt-2" id="categoryMenu">
        <!-- Menu Injected JS -->
      </div>
    </div>

    <div class="mt-auto pt-4 border-top">
      <button class="btn btn-outline-danger w-100 btn-sm" onclick="window.close()">
        <i class="bi bi-box-arrow-right me-2"></i> Keluar
      </button>
    </div>
  </aside>

  <!-- Main Content -->
  <main class="main">
    <header class="d-flex justify-content-between align-items-center mb-5 gap-3">
      <div class="d-flex align-items-center gap-3">
        <button class="btn btn-light border d-lg-none" onclick="toggleSidebar()">
          <i class="bi bi-list fs-4"></i>
        </button>
        <div>
          <h4 class="fw-bold mb-0 text-dark" id="pageTitle">Semua Dokumen</h4>
          <small class="text-muted" id="docCount">Memuat...</small>
        </div>
      </div>
      
      <button class="btn btn-primary rounded-pill px-4 py-2 fw-semibold shadow-sm" data-bs-toggle="modal" data-bs-target="#uploadModal">
        <i class="bi bi-plus-lg me-2"></i> Upload
      </button>
    </header>

    <!-- Search Bar -->
    <div class="row mb-4">
      <div class="col-md-6 col-lg-4">
        <div class="input-group shadow-sm rounded-pill overflow-hidden bg-white border">
          <span class="input-group-text border-0 bg-transparent ps-3"><i class="bi bi-search text-muted"></i></span>
          <input type="text" class="form-control border-0 shadow-none" placeholder="Cari dokumen..." id="searchInput">
        </div>
      </div>
    </div>

    <!-- Grid Container -->
    <div id="contentGrid" class="row g-4"></div>

    <!-- Empty State -->
    <div id="emptyState" class="text-center py-5 d-none">
      <div class="mb-3 text-muted opacity-25">
        <i class="bi bi-folder-x" style="font-size: 4rem;"></i>
      </div>
      <h5 class="fw-bold text-secondary">Tidak ada dokumen</h5>
      <p class="text-muted small">Coba ganti kategori atau unggah file baru.</p>
    </div>
  </main>

  <!-- Modal Upload -->
  <div class="modal fade" id="uploadModal" tabindex="-1" data-bs-backdrop="static">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content border-0 shadow-lg rounded-4">
        <div class="modal-header border-bottom-0 pb-0">
          <h5 class="modal-title fw-bold">Unggah File</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
        </div>
        <div class="modal-body p-4">
          <form id="uploadForm">
            <div class="mb-3">
              <label class="form-label fw-bold text-muted small">Pilih File</label>
              <input type="file" class="form-control" id="fileInput" multiple required>
              <div class="form-text text-warning"><i class="bi bi-exclamation-circle"></i> Disarankan file < 40MB.</div>
            </div>
            
            <div class="mb-3">
              <label class="form-label fw-bold text-muted small">Kategori</label>
              <select class="form-select" id="catInput"></select>
            </div>

            <div class="mb-3" id="customNameGroup" style="display:none;">
              <label class="form-label fw-bold text-muted small">Nama Dokumen</label>
              <input type="text" class="form-control" id="nameInput">
            </div>

            <!-- Progress Bar -->
            <div id="progressContainer" class="d-none mt-4 p-3 bg-light rounded border">
              <div class="d-flex justify-content-between mb-1">
                <span class="small fw-bold text-primary" id="progressStatus">Persiapan...</span>
                <span class="small fw-bold" id="progressPercent">0%</span>
              </div>
              <div class="progress" style="height: 8px;">
                <div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
              </div>
              <small class="d-block mt-2 text-muted fst-italic" style="font-size: 10px;">Jangan tutup halaman ini selama proses berlangsung.</small>
            </div>

            <div class="d-grid mt-4">
              <button type="submit" class="btn btn-primary py-2 fw-bold" id="btnSubmit">Mulai Upload</button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>

  <!-- Javascript Libraries -->
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>

  <!-- Main Logic -->
  <script>
    // --- STATE MANAGEMENT ---
    let documents = [];
    let currentCat = "Semua";
    // Konfigurasi Kategori
    const categories = ["Semua", "Pribadi", "Pendidikan", "Pekerjaan", "Sertifikat","SKP","PAK","SK-SK","FOTO","Video","Lainnya"];
    const loader = document.getElementById('pageLoader');

    // --- INITIALIZATION ---
    window.onload = () => {
      renderMenu();
      fetchData();
      
      // Auto Name Logic
      document.getElementById('fileInput').addEventListener('change', function() {
        if(this.files.length === 1) {
          document.getElementById('customNameGroup').style.display = 'block';
          document.getElementById('nameInput').value = this.files[0].name.split('.')[0];
        } else {
          document.getElementById('customNameGroup').style.display = 'none';
        }
      });

      // Search Listener
      document.getElementById('searchInput').addEventListener('keyup', renderGrid);
    };

    function toggleSidebar() {
      document.getElementById('sidebar').classList.toggle('open');
      document.querySelector('.overlay').classList.toggle('show');
    }

    function renderMenu() {
      const menu = document.getElementById('categoryMenu');
      const select = document.getElementById('catInput');
      
      menu.innerHTML = categories.map(c => `
        <div class="menu-item ${c === currentCat ? 'active' : ''}" onclick="filterCat('${c}')">
          <i class="bi ${c === 'Semua' ? 'bi-grid' : 'bi-folder'}"></i> ${c}
        </div>
      `).join('');

      select.innerHTML = categories.filter(c => c !== 'Semua').map(c => `<option value="${c}">${c}</option>`).join('');
    }

    function filterCat(c) {
      currentCat = c;
      document.getElementById('pageTitle').innerText = c === 'Semua' ? 'Semua Dokumen' : c;
      renderMenu();
      renderGrid();
      if(window.innerWidth < 992) toggleSidebar();
    }

    // --- DATA FETCHING ---
    function fetchData() {
      google.script.run
        .withSuccessHandler(data => {
          documents = data;
          renderGrid();
          loader.classList.add('d-none');
        })
        .withFailureHandler(err => {
          Swal.fire('Gagal Memuat', err.message, 'error');
          loader.classList.add('d-none');
        })
        .getDocuments();
    }

    function renderGrid() {
      const grid = document.getElementById('contentGrid');
      const search = document.getElementById('searchInput').value.toLowerCase();
      
      const filtered = documents.filter(doc => {
        const matchCat = currentCat === 'Semua' || doc.category === currentCat;
        const matchSearch = doc.name.toLowerCase().includes(search);
        return matchCat && matchSearch;
      });

      document.getElementById('docCount').innerText = `${filtered.length} File`;

      if(filtered.length === 0) {
        grid.innerHTML = '';
        document.getElementById('emptyState').classList.remove('d-none');
        return;
      }
      document.getElementById('emptyState').classList.add('d-none');

      grid.innerHTML = filtered.map(doc => {
        const isImg = doc.type.startsWith('image/');
        const icon = isImg ? '' : (doc.type.includes('pdf') ? 'bi-file-earmark-pdf text-danger' : 'bi-file-earmark-text text-primary');
        
        // Thumbnail Fix
        const thumbContent = isImg 
          ? `<img src="https://drive.google.com/thumbnail?id=${doc.driveId}&sz=w800" loading="lazy" style="width:100%; height:100%; object-fit:cover;">` 
          : `<i class="bi ${icon}" style="font-size: 3rem;"></i>`;

        return `
        <div class="col-12 col-md-6 col-lg-4 col-xl-3">
          <div class="doc-card">
            <div class="card-thumb" onclick="window.open('${doc.url}', '_blank')">
              ${thumbContent}
              <span class="position-absolute bottom-0 end-0 m-2 badge bg-dark opacity-75">${doc.type.split('/')[1].toUpperCase()}</span>
            </div>
            <div class="p-3">
              <div class="d-flex justify-content-between mb-2">
                <span class="badge bg-primary bg-opacity-10 text-primary">${doc.category}</span>
                <button class="btn btn-link p-0 text-muted" onclick="deleteDoc('${doc.id}', '${doc.driveId}')"><i class="bi bi-trash"></i></button>
              </div>
              <h6 class="fw-bold text-truncate mb-1" title="${doc.name}">${doc.name}</h6>
              <div class="d-flex justify-content-between text-muted" style="font-size: 11px;">
                <span>${doc.date}</span>
                <span>${doc.size}</span>
              </div>
            </div>
          </div>
        </div>
        `;
      }).join('');
    }

    // --- UPLOAD HANDLER ---
    document.getElementById('uploadForm').addEventListener('submit', async function(e) {
      e.preventDefault();
      
      const files = document.getElementById('fileInput').files;
      const cat = document.getElementById('catInput').value;
      const customName = document.getElementById('nameInput').value;
      
      if(files.length === 0) return;

      const btn = document.getElementById('btnSubmit');
      const progressBox = document.getElementById('progressContainer');
      const bar = document.getElementById('progressBar');
      const txtStatus = document.getElementById('progressStatus');
      const txtPercent = document.getElementById('progressPercent');

      btn.disabled = true;
      progressBox.classList.remove('d-none');

      try {
        for(let i=0; i<files.length; i++) {
          const file = files[i];
          const fileName = (files.length === 1 && customName) ? customName : file.name;

          txtStatus.innerText = `Mengunggah ${i+1}/${files.length}: ${file.name}`;
          
          const base64 = await new Promise((resolve) => {
            const reader = new FileReader();
            reader.onload = (e) => resolve(e.target.result.split(',')[1]);
            reader.readAsDataURL(file);
          });

          const CHUNK_SIZE = 256 * 1024; 
          const totalChunks = Math.ceil(base64.length / CHUNK_SIZE);
          let tempId = null;

          const startRes = await runScript('processChunk', { type: 'start' });
          if(startRes.status !== 'success') throw new Error(startRes.message);
          tempId = startRes.tempId;

          for(let c=0; c<totalChunks; c++) {
            const start = c * CHUNK_SIZE;
            const end = Math.min(start + CHUNK_SIZE, base64.length);
            const chunk = base64.substring(start, end);

            const appendRes = await runScript('processChunk', {
              type: 'append', tempId: tempId, data: chunk
            });
            if(appendRes.status !== 'success') throw new Error(appendRes.message);

            const pct = Math.round(((c + 1) / totalChunks) * 100);
            bar.style.width = `${pct}%`;
            txtPercent.innerText = `${pct}%`;
          }

          txtStatus.innerText = "Finalisasi file...";
          const finishRes = await runScript('processChunk', {
            type: 'finish', tempId: tempId, 
            mimeType: file.type, name: file.name, 
            customName: fileName, category: cat
          });
          if(finishRes.status !== 'success') throw new Error(finishRes.message);
        }

        Swal.fire('Berhasil!', 'Semua file tersimpan.', 'success').then(() => {
          document.getElementById('uploadForm').reset();
          bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
          progressBox.classList.add('d-none');
          btn.disabled = false;
          bar.style.width = '0%';
          loader.classList.remove('d-none');
          fetchData();
        });

      } catch (err) {
        Swal.fire('Gagal', err.message, 'error');
        btn.disabled = false;
        progressBox.classList.add('d-none');
      }
    });

    function runScript(name, params) {
      return new Promise((resolve) => {
        google.script.run
          .withSuccessHandler(resolve)
          .withFailureHandler(err => resolve({status:'error', message: err.message}))
          [name](params);
      });
    }

    function deleteDoc(id, driveId) {
      Swal.fire({
        title: 'Yakin Hapus?', text: "Data tidak bisa kembali.", icon: 'warning',
        showCancelButton: true, confirmButtonColor: '#d33', confirmButtonText: 'Hapus'
      }).then(res => {
        if(res.isConfirmed) {
          loader.classList.remove('d-none');
          google.script.run
            .withSuccessHandler(() => {
              documents = documents.filter(d => d.id !== id);
              renderGrid();
              loader.classList.add('d-none');
              Swal.fire('Terhapus', '', 'success');
            })
            .withFailureHandler(err => {
              loader.classList.add('d-none');
              Swal.fire('Error', err.message, 'error');
            })
            .deleteDocument(id, driveId);
        }
      })
    }
  </script>
</body>
</html>
Langkah 4: Klik tombol Terapkan (Deploy) > Deployment Baru.
  • Pilih jenis: Aplikasi Web.
  • Jalankan sebagai: Saya.
  • Yang memiliki akses: Siapa saja (Anyone).
Terakhir, klik Deploy dan salin URL Web App Anda.

Penutup

Selamat! Sekarang Anda memiliki aplikasi penyimpanan digital pribadi yang canggih tanpa biaya bulanan. Jangan lupa untuk menyimpan link Web App Anda agar bisa diakses kapan saja.