Bagi yang sering mampir ke blog gak berguna ini, pasti sudah familiar dengan “logo” dari blog ini yang memiliki bingkai warna-warni yang mirip dengan yang ada di salah satu media sosial untuk menandakan adanya pembaruan/status. Dan jika logo tersebut di klik, maka akan muncul sesuatu yang kurang lebih seperti ini: Since gue melacak siapapun (selama tidak memblokir skrip umami.js gue) yang melakukan aksi tersebut, gue rasa memiliki fitur “stories” agak potensial, alias, nice to have, jika melihat ke jumlah event yang terlacak. Mungkin ada yang tertarik untuk mengikuti “kabar” harian gue yang eksklusif dalam bentuk media?

Dan juga, sudah lama gue ingin memiliki fitur “stories” ala-ala yang tidak terlekat dengan platform tertentu, dan dari fitur serupa yang ditawarkan oleh penyedia layanan berbeda (Instagram Stories, WhatsApp Status, Facebook Stories, Signal Stories, Snapchat, TikTok Stories, dll) hanya Instagram Stories yang sering gue gunakan, meskipun hanya untuk shitpost. Effort pertama yang gue lakukan adalah dengan membuat bingkai warna-warni diatas, dan membuat *placeholder *ketika ada yang iseng mengkliknya, lalu lupakan, since gue belum memiliki cukup waktu untuk membuatnya berfungsi.

Ide dari fitur stories pada dasarnya adalah untuk membagikan sesuatu dalam jangka waktu tertentu yang umumnya berdurasi 24 jam setelah pembaruan tersebut dipublikasikan. That’s it. Yang membuatnya kompleks adalah fitur-fitur pendukung seperti stiker, filter, analitik, editor, visibilitas, komentar, reaksi, kompresi, interaksi dsb dsb.

Di hari Sabtu kemarin ketika sedang iseng buka Github, salah satu teman gue memfavoritkan sebuah repositori yang bernama PocketBase. Gue baca sekilas dokumentasinya dan sepertinya menjanjikan. Setelah menjalankan instance tersebut di server rumah (yang hanya memakan waktu 10 menit, literally segampang itu), gue membuat prototype di Codepen untuk membuat fitur “stories”, dan hanya memakan waktu 2 jam.

Benar-benar segampang itu menggunakan PocketBase.

Sebelumnya gue menggunakan Postgrest untuk membuat “backend” nya dan mempertimbangkan untuk menggunakan Supabase sebagai alternatif. Pada akhirnya backend tersebut tidak pernah selesai, dan ketika menggunakan PocketBase, hanya memakan waktu sekitar 2 jam yang bahkan bila hanya membuat backend (oversimplified) nya saja tidak lebih dari 10 menit.

Semenjak hari pertama menjalankan PocketBase gue sudah jatuh cinta dan sepertinya akan sering gue gunakan untuk membuat backend yang tidak memiliki fitur kompleks.

Menjalankan Pocketbase

Basis data yang digunakan PocketBase adalah SQLite, dan basis data ini sudah lebih dari cukup. Berbicara tentang skalabilitas, SQLite sudah cukup teruji dan untuk “scaling” efektif nya menggunakan vertikal daripada horizontal, kecuali, mungkin, bila menggunakan Litestream. Di banyak kasus, 1 mesin seharusnya cukup jika hanya untuk mengatur trafik keluar-masuk, khususnya bila hanya untuk aplikasi sistem informasi.

Dengan menggunakan Docker Compose, langkah yang dilakukan adalah membuat berkas seperti berikut:

version: "3"

services:
  pocketbase:
    restart: always
    image: ghcr.io/muchobien/pocketbase:latest
    command:
      - --encryptionEnv
      - ENCRYPTION
    environment:
      ENCRYPTION: # generate with $(openssl rand -hex 16)
    networks:
      - cloudflared
    volumes:
      - pocketbase_data:/pb_data
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://127.0.0.1:8090/api/health || exit 1
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pocketbasedata:/pb_data

networks:
  cloudflared:

Dan menjalankan docker compose up -d jika berkas tersebut bernama docker-compose.yml.

Lalu “dashboard” bisa diakses via port 8090, yang of course perlu dilindungi dibelakang reverse proxy. https://rizaldy.today/_/ Jika penasaran dengan konfigurasi Nginx gue, berikut rules nya:

location /api {
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Host $host;

  proxy_pass http://pocketbase:8090;
}

location /_ {
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Host $host;

  proxy_pass http://pocketbase:8090;
}

Ada alasan khusus mengapa tidak meneruskan semua permintaan ke instance PocketBase, stay tune!

Membuat collections

Kolom yang gue gunakan hanya 3: notes (text), expired (datetime), dan media (file, di handle oleh PocketBase).

PocketBase mengelola kolom khusus yakni id, created dan updated dan gue perlu kolom expired karena di PocketBase belum mendukung advance query seperti now() - interval '1 day' nya Postgres. Karena umumnya stories hanya berlaku selama 24 jam, di API rules nya gue definisikan seperti ini: Agar hanya bisa menampilkan data (stories) yang relevan (last 24h).

Dan karena menggunakan zona waktu UTC, setidaknya hanya perlu menambahkan 7 jam jika ingin relevan dengan waktu yang digunakan oleh pengguna internet di Indonesia.

Setup Minio

PocketBase menawarkan fitur untuk mengatur unggahan, yang default nya menggunakan file system lokal (jika menggunakan Docker berada di /pb_public). Karena gue sudah menggunakan Minio, gue akan memaksimalkan apa yang sudah ada dan terlebih Minio memiliki fitur advanced yang mungkin akan berguna di kemudian hari. Jika menggunakan file system local, mungkin bisa menggunakan [cron(8)](https://linux.die.net/man/8/cron) untuk melakukan cleanup setiap hari, jika ingin. Dan karena gue menggunakan Minio, ini bisa diatur di pengaturan Object Lifecycle menggunakan rules Expiry. Melakukan cleanup/purging ini bersifat opsional. Umumnya, sistem di sebuah aplikasi menggunakan “soft delete” untuk menandakan data yang ehm “dihapus”, ini wajib menggunakan tanda kutip. Jika menggunakan pendekatan gue, objek yang ada di Minio akan benar-benar dihapus dan gue rasa gue akan mematikan rules tersebut HAHAHA.

Membuat SDK

Ini adalah fitur inti dari si stories. Kode ini masih berantakan, bukan dimaksudkan untuk di lingkungan production, tapi setidaknya it works. Tidak ada komentar apapun (selain todo) terkait kode yang gue buat selama 2 jam ini (hackaton level), tapi gue rasa relatif mudah dipahami jika mengerti sedikit bagaimana fitur stories bekerja (pembelaan).

  const triggerer = ".cover-icon";
  const triggererInner = ".cover-icon-image";

  const API_URL = "https://rizaldy.today/api";
  const RECORDS = "/collections/stories/records";
  const FILES = "/files";
  const STORY_DURATION = 5;

  document.querySelector(triggerer).addEventListener("click", openStories);

  const SELECTOR = {
    ROOT: "stories",
    STORIES_WRAPPER: "stories__wrapper",
    STORIES_ITEM_WRAPPER: "stories__item-wrapper",
    STORIES_ITEM_BAR_WRAPPER: "stories__item-bar-wrapper",
    STORIES_ITEM: "stories__item",
    STORIES_ITEM_IS_SHOW: "stories__item--show",
    STORIES_ITEM_BAR: "stories__item-bar",
    STORIES_ITEM_BAR_IS_SEEN: "stories__item-bar--seen",
    STORIES_ITEM_BAR_IS_PROGRESS: "stories__item-bar--progress",
    STORIES_MEDIA: "stories__media",
    STORIES_NOTES: "stories__notes"
  };

  let activeStory = 0;
  let ticker = 0;
  let nice = 0;
  let stories = [];

  let tick;
  let wkwk;

  const hasStories = () => stories.length !== 0;
  const lastStories = () => activeStory > stories.length - 1;

  async function bootstrap() {
    const root = document.createElement("div");
    root.classList.add(SELECTOR.ROOT);

    const storiesWrapper = document.createElement("div");
    storiesWrapper.classList.add(SELECTOR.STORIES_WRAPPER);

    const storiesItemBarWrapper = document.createElement("div");
    storiesItemBarWrapper.classList.add(SELECTOR.STORIES_ITEM_BAR_WRAPPER);

    const storiesItemWrapper = document.createElement("div");
    storiesItemWrapper.classList.add(SELECTOR.STORIES_ITEM_WRAPPER);

    storiesWrapper.appendChild(storiesItemBarWrapper);
    storiesWrapper.appendChild(storiesItemWrapper);

    root.appendChild(storiesWrapper);

    document.body.appendChild(root);
  }

  function cleanup (withoutSeen) {
    document.getElementsByClassName(SELECTOR.ROOT)[0].remove();
    
    if (!withoutSeen) {
        document.querySelector(triggerer).style.padding = "0px";
        document.querySelector(triggerer).style.backgroundImage = "none";
        document.querySelector(triggererInner).style.backgroundColor = "transparent";
        document.querySelector(triggererInner).style.border = "2px solid #ccc";
    }

    ticker = 0;
    nice = 0;
    activeStory = 0;
      
    clearInterval(tick);
    clearInterval(wkwk);
  }
    
    window.addEventListener('keydown', e => {
      const isOpeningStory = document.getElementsByClassName(SELECTOR.ROOT)[0]
      const isEscape = e.key === 'Escape' || e.key == 'Esc' || e.keyCode === 27

      if (isEscape && isOpeningStory) {
        e.preventDefault();
        cleanup(true);

        return
      }
  });
    
  function renderStoriesBar(stories) {
    const storiesBarWrapper = document.getElementsByClassName(
      SELECTOR.STORIES_ITEM_BAR_WRAPPER
    );

    for (let _story in stories) {
      const bar = document.createElement("div");

      bar.classList.add(SELECTOR.STORIES_ITEM_BAR);

      storiesBarWrapper[0].appendChild(bar);
    }
  }

  function renderStories(stories) {
    const storiesWrapper = document.getElementsByClassName(
      SELECTOR.STORIES_ITEM_WRAPPER
    );

    for (let story in stories) {
      const item = document.createElement("div");
      item.classList.add(SELECTOR.STORIES_ITEM);

      // TODO: handle non image
      const media = document.createElement("img");
      media.classList.add(SELECTOR.STORIES_MEDIA);
      media.src = `${API_URL}${FILES}/${stories[story].collectionId}/${stories[story].id}/${stories[story].file}`;

      const notesWrapper = document.createElement("div");
      notesWrapper.classList.add(SELECTOR.STORIES_NOTES);

      const notes = document.createElement("p");
      notes.innerText = stories[story].notes;

      const timestamp = document.createElement("time");
      timestamp.innerText = "at " + stories[story].created;

      notesWrapper.appendChild(notes);
      notesWrapper.appendChild(timestamp);

      item.appendChild(media);
      item.appendChild(notesWrapper);

      storiesWrapper[0].appendChild(item);
    }
  }

  async function getStories() {
    if (hasStories()) {
      return stories;
    }

    const fetchStories = await fetch(API_URL + RECORDS);
    const json = await fetchStories.json();

    return json.items;
  }

  async function openStories() {
    stories = await getStories(stories);
      
    if (!hasStories()) {
     return;   
    }
      
    await bootstrap();

    if (hasStories()) {
      renderStoriesBar(stories);
      renderStories(stories);
    }

    // first time
    setTimeout(() => {
      var x = document.getElementsByClassName(SELECTOR.STORIES_ITEM);

      x[activeStory].classList.add(SELECTOR.STORIES_ITEM_IS_SHOW);
    }, 10);

    var x = document.getElementsByClassName(SELECTOR.STORIES_ITEM);
    const storyBar = document.getElementsByClassName(
      SELECTOR.STORIES_ITEM_BAR
    );

    wkwk = setInterval(() => {
      if (storyBar[activeStory]) {
        storyBar[activeStory].style.setProperty(
          "--stories-item-bar-width",
          nice + "%"
        );
      }

      nice++;
    }, 50);

    if (hasStories()) {
      tick = setInterval(() => {
        if (lastStories()) {
          clearInterval(tick);
          clearInterval(wkwk);
          cleanup();

          return;
        }

        if (storyBar[activeStory]) {
          storyBar[activeStory].classList.add(
            SELECTOR.STORIES_ITEM_BAR_IS_PROGRESS
          );
        }

        if (ticker && ticker % STORY_DURATION === 0) {
          nice = 0;

          if (storyBar[activeStory]) {
            storyBar[activeStory].classList.remove(
              SELECTOR.STORIES_ITEM_BAR_IS_PROGRESS
            );

            storyBar[activeStory].classList.add(
              SELECTOR.STORIES_ITEM_BAR_IS_SEEN
            );
          }

          if (activeStory !== stories.length - 1) {
            if (x[activeStory]) {
              x[activeStory].classList.remove(
                SELECTOR.STORIES_ITEM_IS_SHOW
              );
            }
          }

          activeStory++;

          if (x[activeStory]) {
            x[activeStory].classList.add(SELECTOR.STORIES_ITEM_IS_SHOW);
          }
        }

        ticker++;
      }, 1000);
    }
  }
    
  // i guess?
  Promise.resolve(getStories()).then(prefetchedStories => {
    if (prefetchedStories.length) {
      // todo: prefetch media bcs why not
      stories = prefetchedStories

      const lalala = document.querySelector(triggerer);
      const yes = document.querySelector(triggererInner);

      if (lalala) {
        // todo ni harusnya kek something selector.classList.add('selector--has-stories')
        lalala.style.backgroundImage = "linear-gradient(rgb(186, 62, 138), #fba051)";
        lalala.style.padding = "4px";
      }
    }
  })

Nantinya akan gue buat kode tersebut dapat dimuat via URL sehingga mungkin bisa digunakan oleh siapapun yang tertarik juga.

Kode CSS nya hanya seperti ini so far:

  .stories {
    background-color: rgba(0, 0, 0, 0.8);
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100vh;
    font-family: "Inter", sans-serif;
    color: #333;
    z-index: 1337;
  }

  .stories__wrapper {
    max-width: 666px;
    margin: auto;
    margin-top: 2rem;
  }

  .stories__item-wrapper {
    background-color: #fff;
    border-radius: 5px;
    overflow: hidden;
    padding-bottom: 1rem;
  }

  .stories__item {
    width: 100%;
    height: 0;
    opacity: 0;
    transition: 0.3s opacity;
  }

  .stories__item--show {
    width: 100%;
    height: 100%;
    min-height: 30vh;
    opacity: 1;
  }

  .stories__notes {
    padding: 1rem;
  }

  .stories__notes p {
    margin-bottom: 0;
    line-height: 1.8rem;
  }

  .stories__notes time {
    font-size: 0.7rem;
    color: #666;
  }

  .stories__media {
    width: 100%;
    display: block;
  }

  .stories__item-bar-wrapper {
    display: flex;
    gap: 10px;
    padding: 5px 10px;
    margin-bottom: -15px;
  }

  .stories__item-bar {
    z-index: 1;
    height: 6px;
    background: rgba(0, 0, 0, 0.4);
    border-radius: 100px;
    flex: 1;
    overflow: hidden;
  }

  .stories__item-bar--seen::before,
  .stories__item-bar--progress::before {
    background-color: #ccc;
    content: "";
    height: 6px;
    display: block;
    border-radius: 100px;
  }

  .stories__item-bar--seen::before {
    width: 100%;
  }

  .stories__item-bar--progress::before {
    width: var(--stories-item-bar-width, 20%);
  }

Semakin lama gue ngoding, semakin gak bener menamakan sesuatu hahaha.

Penutup

Ada beberapa “gotcha” yang sudah masuk ke radar gue.

Pertama, make sure invalidate cache di reverse proxy ketika sudah waktunya, sehingga media yang seharusnya tidak bisa diakses lagi, tidak bisa diakses lagi.

Kedua, ini baru berlaku ke media gambar. Untuk video coming soon karena berurusan dengan “ticker” agak tricky (refer ke bagian setInterval).

Ketiga, penampilan media masih ngasal. Untuk sekarang gue menggunakan width 100% dan membiarkan browser untuk mengatur height nya yang menurut doi proporsional. Ini gue rasa akan bermasalah ketika berurusan dengan media yang menggunakan ukuran 1080x1920 (vertikal) sehingga ada scroll yang membuat tampilan tidak a e s t h e t i c.

Terakhir, belum ada “user preferences” yang bisa dikontrol oleh pengguna. Misal, mematikan fitur stories. Atau mematikan fitur prefetch. Atau memiliki fitur untuk melacak stories apa saja yang sudah dilihat dan yang belum. Ini penting karena pengunjung blog gue memiliki hak untuk dapat menentukan itu.

Selain itu, tampilan dari stories ini pun tidak terlihat seperti pada umumnya, namun yang penting, sudah tidak lagi menampilkan alert bagi siapapun yang mengkliknya lol.

Sebagai penutup, gue berharap jika suatu saat (atau waktunya berkontribusi?) PocketBase mendukung fitur “object expiration” jika menggunakan Minio yang mana menggunakan Presigned URLs sehingga masa berlaku diatur berdasarkan signature yang dibuat, yang memungkinkan aktivitas “soft delete” menjadi lebih efektif serta relatif lebih aman.

Dan maksud utama gue membuat fitur ini adalah untuk berhenti memberi makan server Meta yang layanannya dilindungi autentikasi, tanpa mengorbankan online presence gue yang tidak penting-penting amat.

Anyway, semoga fitur ini berguna atau mungkin setidaknya menyenangkan.

See you there!