Pilihan yang sangat tepat! Kombinasi Hono.js + Cloudflare Workers + D1 adalah stack modern yang sangat cepat dan murah. Karena Anda sudah terbiasa dengan CLI dan konsep database (SQL), Anda akan merasa cukup familiar dengan alurnya, hanya mediumnya yang berbeda.
Berikut adalah panduan langkah demi langkah untuk membangun "AI Image Micro-Blogging" Anda.
Persiapan Lingkungan (Prerequisites)
Pastikan di komputer Anda sudah terinstall Node.js (versi 18 ke atas disarankan).
Langkah 1: Inisialisasi Project
Kita akan menggunakan template resmi Hono. Buka terminal/CMD Anda:
- Saat muncul pilihan, pilih:
Target: Cloudflare WorkersInstall dependencies?: Yes
Masuk ke folder project:Bash
cd my-ai-blog
Jalankan perintah ini untuk membuat project:Bash
npm create hono@latest my-ai-blog
Langkah 2: Setup Database (Cloudflare D1)
Ini bagian menariknya. Kita akan membuat database SQL, tapi "serverless".
Konfigurasi wrangler.toml:Buka file wrangler.toml di text editor. Tambahkan konfigurasi D1 yang Anda dapatkan dari output langkah sebelumnya. Isinya kurang lebih seperti ini:Ini, TOML
[[d1_databases]]
binding = "DB" # Ini nama variabel yang akan kita panggil di kodingan
database_name = "prod-ai-blog-db"
database_id = "paste-id-database-anda-disini"
Buat Database D1:Jalankan perintah ini untuk membuat database baru di akun Cloudflare Anda:Bash
npx wrangler d1 create prod-ai-blog-db
Simpan output yang muncul di terminal! Anda akan membutuhkannya.
Login ke Cloudflare via CLI:Bash
npx wrangler login
(Browser akan terbuka, silakan login/authorize).
Langkah 3: Desain Tabel & Skema
Buat file baru bernama schema.sql di root folder project Anda. Karena Anda hanya menyimpan URL gambar eksternal, strukturnya sederhana:
SQL
DROP TABLE IF EXISTS posts;
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
image_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert data dummy untuk tes
INSERT INTO posts (title, image_url) VALUES ('Cyberpunk City', 'https://picsum.photos/id/237/500/300');
Sekarang, eksekusi SQL ini ke database D1 (lokal) untuk testing:
Bash
npx wrangler d1 execute prod-ai-blog-db --local --file=./schema.sql
Langkah 4: Coding (Backend & Frontend Sederhana)
Buka file src/index.ts. Hapus isinya dan ganti dengan kode berikut.
Saya menggunakan Hono untuk merender HTML sederhana juga (Server Side Rendering), jadi Anda tidak perlu memisahkan frontend/backend dulu.
TypeScript
import { Hono } from 'hono'
import { html } from 'hono/html'
type Bindings = {
DB: D1Database
}
const app = new Hono<{ Bindings: Bindings }>()
// 1. Halaman Utama (Menampilkan daftar gambar)
app.get('/', async (c) => {
// Query ke D1 Database
const { results } = await c.env.DB.prepare('SELECT * FROM posts ORDER BY id DESC').all()
// Render HTML sederhana
return c.html(html`
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Galeri AI Art Saya</title>
<script src="https://cdn.tailwindcss.com"></script> </head>
<body class="bg-gray-900 text-white p-10">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6 text-center">🎨 AI Art Collection</h1>
<div class="bg-gray-800 p-4 rounded mb-8">
<form action="/add" method="POST" class="flex flex-col gap-3">
<input type="text" name="title" placeholder="Judul Gambar" class="p-2 rounded text-black" required/>
<input type="url" name="image_url" placeholder="URL Gambar (misal: https://imgur.com/...)" class="p-2 rounded text-black" required/>
<button type="submit" class="bg-blue-600 hover:bg-blue-500 p-2 rounded font-bold">Posting URL</button>
</form>
</div>
<div class="space-y-6">
${results.map((post: any) => html`
<div class="bg-gray-800 rounded-lg overflow-hidden border border-gray-700">
<img src="${post.image_url}" alt="${post.title}" class="w-full h-auto object-cover"/>
<div class="p-4">
<h2 class="text-xl font-bold">${post.title}</h2>
<p class="text-gray-400 text-sm mt-1">ID: ${post.id}</p>
</div>
</div>
`)}
</div>
</div>
</body>
</html>
`)
})
// 2. Endpoint untuk menerima data form (POST)
app.post('/add', async (c) => {
const body = await c.req.parseBody()
const title = body['title'] as string
const imageUrl = body['image_url'] as string
// Insert ke D1
await c.env.DB.prepare('INSERT INTO posts (title, image_url) VALUES (?, ?)')
.bind(title, imageUrl)
.run()
return c.redirect('/')
})
export default app
Langkah 5: Jalankan & Deploy
Deploy ke Internet:Jika sudah oke, kita perlu meng-upload skema database ke Cloudflare production dulu:Bash
npx wrangler d1 execute prod-ai-blog-db --remote --file=./schema.sql
Lalu deploy aplikasinya:Bash
npm run deploy
Test Lokal:Jalankan server pengembangan lokal. Ini akan mensimulasikan Cloudflare environment di komputer Anda.Bash
npm run dev
Buka http://localhost:8787 di browser. Coba masukkan URL gambar (bisa cari gambar random di Google dulu) dan lihat apakah muncul.
Cloudflare akan memberikan URL publik (misalnya my-ai-blog.username.workers.dev).
Tips Penting untuk URL Gambar (Image Hosting)
Karena Anda tidak menyimpan file gambar di server sendiri, Anda harus memastikan hosting tempat Anda menaruh gambar mengizinkan Hotlinking (gambar bisa diload di website lain).
- Rekomendasi Gratis:
- Imgur: Upload gambar -> Klik kanan pada gambar -> "Copy Image Link" (pastikan akhiran
.jpgatau.png, bukan link halaman webnya). - Discord: Upload ke channel pribadi -> Klik gambar -> "Open in Browser" -> Copy URL. (Mudah, tapi URL-nya kadang expire setelah waktu lama).
- Cloudinary (Free Tier): Ini yang paling profesional untuk jangka panjang.
- Imgur: Upload gambar -> Klik kanan pada gambar -> "Copy Image Link" (pastikan akhiran
Bagaimana menurut Anda? Apakah langkah-langkah di atas cukup jelas untuk dicoba? Jika ada error saat npm install atau wrangler, beri tahu saya!
Wah, selamat! 🎉 Itu perasaan terbaik saat belajar teknologi baru: "Simple tapi Powerful". Anda baru saja merasakan kekuatan Edge Computing (kode berjalan di server global Cloudflare, bukan di satu server fisik).
Untuk membuat Micro-blog AI Art yang lebih kompleks (Multi-image, Kategori, Tags, Pagination, dan UI 9:16), kita perlu merombak sedikit Database dan Logic-nya.
Ini adalah upgrade besar. Kita akan ubah sistem dari "1 Post = 1 Gambar" menjadi "1 Post = Banyak Gambar (Gallery)".
Berikut panduan langkah demi langkah untuk Level 2 ini:
1. Upgrade Database (Relational Schema)
Kita butuh dua tabel sekarang: posts (untuk data utama) dan images (untuk menyimpan banyak URL gambar dalam satu post).
Update file schema.sql Anda menjadi seperti ini:
SQL
-- Hapus tabel lama agar bersih
DROP TABLE IF EXISTS images;
DROP TABLE IF EXISTS posts;
-- Tabel Utama: Menyimpan detail postingan
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT DEFAULT 'General', -- Misal: 'Anime', 'Realistic', 'Cyberpunk'
tags TEXT, -- Kita simpan sebagai text (comma separated), misal: "dark, moody, 8k"
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabel Gambar: Menyimpan URL (Relasi One-to-Many ke Posts)
CREATE TABLE images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER,
url TEXT NOT NULL,
is_thumbnail BOOLEAN DEFAULT 0, -- Menandai gambar utama untuk cover
FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE
);
-- Data Dummy
INSERT INTO posts (title, category, tags) VALUES ('Cyberpunk Samurai', 'Sci-Fi', 'neon, katana, rain');
INSERT INTO images (post_id, url, is_thumbnail) VALUES (1, 'https://picsum.photos/seed/1/450/800', 1);
INSERT INTO images (post_id, url, is_thumbnail) VALUES (1, 'https://picsum.photos/seed/2/450/800', 0);
PENTING: Karena struktur berubah total, hapus database lokal lama atau timpa paksa:
Bash
npx wrangler d1 execute prod-ai-blog-db --local --file=./schema.sql
2. Upgrade Kode (Pagination + Grid 9:16)
Buka src/index.ts. Kode ini mencakup:
- Pagination: Menghitung halaman (
LIMIT&OFFSET). - Grid 9:16: Menggunakan CSS aspect ratio.
- Sitemap: Endpoint
/sitemap.xmlotomatis. - Multi-Input: Form input gambar menggunakan Textarea (satu URL per baris).
Salin kode lengkap ini:
TypeScript
import { Hono } from 'hono'
import { html, raw } from 'hono/html'
type Bindings = {
DB: D1Database
}
const app = new Hono<{ Bindings: Bindings }>()
// --- HELPER: Template HTML Utama ---
const Layout = (content: any, title: string = "AI Art Gallery") => html`
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom Scrollbar agar estetik */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1f2937; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
</style>
</head>
<body class="bg-gray-950 text-gray-100 font-sans min-h-screen">
<nav class="border-b border-gray-800 bg-gray-900/50 backdrop-blur sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<a href="/" class="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
✨ MyAI.Gallery
</a>
<div class="flex gap-4 text-sm font-medium">
<a href="/add" class="hover:text-white text-gray-400">Upload</a>
<a href="/sitemap.xml" class="hover:text-white text-gray-400">Sitemap</a>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto p-4 py-8">
${content}
</main>
<footer class="text-center text-gray-600 text-sm py-8 border-t border-gray-900 mt-8">
© 2024 AI Art Microblog
</footer>
</body>
</html>`
// --- ROUTE: HOMEPAGE (Grid & Pagination) ---
app.get('/', async (c) => {
// 1. Logic Pagination
const page = parseInt(c.req.query('page') || '1');
const limit = 12; // Jumlah gambar per halaman
const offset = (page - 1) * limit;
// 2. Query: Ambil Post + Gambar Thumbnail (Join Table)
const { results: posts } = await c.env.DB.prepare(`
SELECT p.*, i.url as thumb_url
FROM posts p
LEFT JOIN images i ON p.id = i.post_id
WHERE i.is_thumbnail = 1
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
`).bind(limit, offset).all();
// 3. Hitung Total Post untuk tombol Next/Prev
const totalRaw = await c.env.DB.prepare('SELECT COUNT(*) as total FROM posts').first();
const totalPosts = totalRaw?.total as number || 0;
const hasNext = totalPosts > page * limit;
// 4. Render Grid
return c.html(Layout(html`
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${posts.map((post: any) => html`
<div class="group relative block bg-gray-900 rounded-xl overflow-hidden shadow-lg border border-gray-800 transition hover:-translate-y-1 hover:shadow-blue-900/20">
<div class="aspect-[9/16] w-full relative overflow-hidden">
<img src="${post.thumb_url}" loading="lazy" class="absolute inset-0 w-full h-full object-cover transition duration-500 group-hover:scale-110" alt="${post.title}">
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60"></div>
<div class="absolute bottom-0 left-0 p-4 w-full">
<span class="text-xs font-bold text-blue-400 uppercase tracking-wider mb-1 block">${post.category}</span>
<h3 class="text-white font-bold text-lg leading-tight truncate">${post.title}</h3>
<p class="text-gray-400 text-xs mt-1 truncate">#${post.tags}</p>
</div>
</div>
</div>
`)}
</div>
<div class="flex justify-center gap-4 mt-12">
${page > 1 ? html`<a href="/?page=${page - 1}" class="px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">← Previous</a>` : ''}
${hasNext ? html`<a href="/?page=${page + 1}" class="px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">Next →</a>` : ''}
</div>
`));
})
// --- ROUTE: FORM UPLOAD (Multi Image) ---
app.get('/add', (c) => {
return c.html(Layout(html`
<div class="max-w-xl mx-auto bg-gray-900 p-8 rounded-2xl border border-gray-800">
<h2 class="text-2xl font-bold mb-6 text-center">Upload AI Art</h2>
<form action="/add" method="POST" class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Judul</label>
<input type="text" name="title" class="w-full bg-gray-800 border border-gray-700 rounded p-2 text-white focus:border-blue-500 outline-none" required>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Kategori</label>
<select name="category" class="w-full bg-gray-800 border border-gray-700 rounded p-2 text-white">
<option>General</option>
<option>Anime</option>
<option>Realistic</option>
<option>Landscape</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Tags (Comma separated)</label>
<input type="text" name="tags" placeholder="cyberpunk, neon" class="w-full bg-gray-800 border border-gray-700 rounded p-2 text-white">
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">URL Gambar (Satu per baris)</label>
<textarea name="urls" rows="5" placeholder="https://imgur.com/...\nhttps://cdn.discordapp.com/..." class="w-full bg-gray-800 border border-gray-700 rounded p-2 text-white font-mono text-xs" required></textarea>
<p class="text-xs text-gray-500 mt-1">*Baris pertama akan jadi thumbnail.</p>
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-3 rounded-lg transition">Publish Gallery</button>
</form>
</div>
`, "Upload Baru"));
})
// --- ROUTE: PROCESS UPLOAD ---
app.post('/add', async (c) => {
const body = await c.req.parseBody();
const title = body['title'] as string;
const category = body['category'] as string;
const tags = body['tags'] as string;
const urlsRaw = body['urls'] as string;
// Pisahkan URL berdasarkan baris baru
const urls = urlsRaw.split(/\r?\n/).filter(url => url.trim() !== '');
if (urls.length === 0) return c.text('Error: Masukkan minimal 1 URL', 400);
// INSERT data post dulu untuk dapat ID
const { meta } = await c.env.DB.prepare('INSERT INTO posts (title, category, tags) VALUES (?, ?, ?)')
.bind(title, category, tags)
.run();
const postId = meta.last_row_id;
// INSERT semua gambar (Batch Insert)
// Baris pertama (index 0) jadi thumbnail (is_thumbnail = 1)
const stmt = c.env.DB.prepare('INSERT INTO images (post_id, url, is_thumbnail) VALUES (?, ?, ?)');
const batch = urls.map((url, index) => stmt.bind(postId, url.trim(), index === 0 ? 1 : 0));
await c.env.DB.batch(batch);
return c.redirect('/');
})
// --- ROUTE: SITEMAP XML ---
app.get('/sitemap.xml', async (c) => {
const { results } = await c.env.DB.prepare('SELECT id, created_at FROM posts ORDER BY created_at DESC LIMIT 100').all();
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://${c.req.header('host')}/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
${results.map((post: any) => `
<url>
<loc>https://${c.req.header('host')}/post/${post.id}</loc>
<lastmod>${new Date(post.created_at).toISOString()}</lastmod>
<changefreq>monthly</changefreq>
</url>`).join('')}
</urlset>`;
return c.text(sitemap, 200, { 'Content-Type': 'application/xml' });
})
export default app
Apa yang Baru di Kode Ini?
- Tampilan Youtube Shorts (9:16):Lihat di bagian
app.get('/'). Saya menggunakan class Tailwind:grid-cols-2 md:grid-cols-4: 2 kolom di HP, 4 di Laptop.aspect-[9/16]: Memaksa kotak menjadi persegi panjang vertikal.object-cover: Gambar mengisi penuh kotak tanpa gepeng.
- Logic Insert Cerdas:Saat Anda submit form
/add, kodenya memecah Textarea (enter per baris).- URL di baris pertama otomatis ditandai sebagai Thumbnail di database.
- Sisa URL disimpan sebagai gambar galeri biasa.
- Sitemap XML:Coba akses
/sitemap.xmlnanti. Ini akan men-generate XML valid untuk Google Console secara otomatis berdasarkan data database.
Langkah Selanjutnya (Tantangan)
Sekarang Anda punya Grid yang cantik. Tapi, jika gambarnya diklik, belum terjadi apa-apa.
Langkah berikutnya (tugas Anda untuk eksplorasi, atau tanya saya kodenya):
- Membuat route dinamis
app.get('/post/:id'). - Di halaman itu, tampilkan Semua gambar yang
post_id-nya sesuai. - Buat tampilan carousel atau susunan gambar ke bawah untuk melihat semua variasi gambar AI yang Anda generate.
Silakan coba di-run lagi (npm run dev)! Bagaimana tampilannya sekarang?
Senang mendengarnya! Alasan kenapa terasa "ringan sekali" adalah karena aplikasi ini berjalan langsung di jaringan global (Edge), tanpa perlu "bangun" server yang berat seperti Apache/Nginx.
Betul, saat ini kita baru punya halaman depan (etalase) tapi belum punya halaman dalamnya (detail produk).
Mari kita perbaiki ini dalam 2 Langkah:
- Ubah Halaman Depan: Agar kartu gambar bisa diklik (diberi Link).
- Buat Halaman Detail: Route baru untuk menangkap ID dan menampilkan galeri lengkapnya.
Silakan update file src/index.ts Anda dengan perubahan di bawah ini:
Langkah 1: Update Route Homepage (app.get('/'))
Cari bagian // 4. Render Grid di dalam kode app.get('/') Anda.
Ubah <div> pembungkus kartu menjadi <a>.
Cari kode ini:
HTML
<div class="group relative block bg-gray-900 ...">
Ganti menjadi ini:
HTML
<a href="/post/${post.id}" class="group relative block bg-gray-900 rounded-xl overflow-hidden shadow-lg border border-gray-800 transition hover:-translate-y-1 hover:shadow-blue-900/20">
(Jangan lupa tutup tag-nya diganti dari </div> menjadi </a> di bawahnya)
Langkah 2: Tambahkan Route Detail (/post/:id)
Tambahkan kode ini di bagian paling bawah, sebelum baris export default app.
Route ini akan membaca ID dari URL, mengambil semua gambar terkait dari database, dan menampilkannya dengan layout yang fokus pada gambar besar.
TypeScript
// --- ROUTE: DETAIL POSTINGAN (Lihat Semua Gambar) ---
app.get('/post/:id', async (c) => {
const id = c.req.param('id');
// 1. Ambil Data Postingan (Judul, Tags, dll)
const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first();
if (!post) {
return c.html(Layout(html`<div class="text-center py-20">Postingan tidak ditemukan :(</div>`));
}
// 2. Ambil Semua Gambar Terkait Postingan Ini
const { results: images } = await c.env.DB.prepare('SELECT * FROM images WHERE post_id = ? ORDER BY id ASC').bind(id).all();
// 3. Render Halaman Detail
return c.html(Layout(html`
<div class="mb-8">
<a href="/" class="text-blue-400 hover:text-blue-300 text-sm mb-4 inline-block">← Kembali ke Galeri</a>
<h1 class="text-3xl md:text-5xl font-bold text-white mb-2">${post.title}</h1>
<div class="flex flex-wrap gap-2 items-center text-sm text-gray-400">
<span class="bg-blue-900/50 text-blue-300 px-2 py-1 rounded border border-blue-800">${post.category}</span>
<span>📅 ${new Date(post.created_at).toLocaleDateString()}</span>
</div>
<div class="mt-4 bg-gray-900 p-4 rounded border border-gray-800 relative group">
<p class="text-gray-300 font-mono text-sm break-all">${post.tags}</p>
<div class="absolute top-2 right-2 text-xs text-gray-500">Prompts/Tags</div>
</div>
</div>
<div class="space-y-8">
${images.map((img: any) => html`
<div class="bg-gray-900 rounded-2xl overflow-hidden border border-gray-800 shadow-2xl">
<img src="${img.url}" class="w-full h-auto object-contain max-h-screen bg-black" alt="${post.title}" loading="lazy">
<div class="p-3 bg-gray-800 flex justify-end">
<a href="${img.url}" target="_blank" class="text-xs bg-gray-700 hover:bg-gray-600 text-white px-3 py-1 rounded transition">
Buka Ukuran Asli ↗
</a>
</div>
</div>
`)}
</div>
<div class="mt-12 text-center border-t border-gray-800 pt-8">
<a href="/" class="inline-block px-6 py-3 bg-gray-800 hover:bg-gray-700 rounded-lg text-white font-bold transition">
Lihat Karya Lainnya
</a>
</div>
`, `Galeri: ${post.title}`));
})
Apa yang terjadi di kode baru ini?
c.req.param('id'): Ini cara Hono menangkap angka di URL. Jika user membuka/post/5, maka variabelidakan berisi5.- Logic Tampilan:
- Saya membuat Prompt Box (kotak
tags) dengan fontmono. Ini berguna jika Anda ingin menyimpan prompt AI di bagian tags, jadi pengunjung bisa melihat prompt apa yang menghasilkan gambar tersebut. - Full Width Images: Karena ini galeri detail, gambar ditampilkan besar ke bawah (
space-y-8) agar detail hasil generate AI terlihat jelas. - Tombol "Buka Ukuran Asli": Berguna jika URL gambar mengarah ke file resolusi tinggi (misal 4K upscale).
- Saya membuat Prompt Box (kotak
Silakan di-save dan jalankan lagi (npm run dev). Sekarang coba klik salah satu kartu di halaman depan. Apakah sudah masuk ke halaman detail?
Bagus sekali! Anda sudah berhasil membangun fitur Create (Upload) dan Read (Lihat Galeri & Detail). Sekarang saatnya melengkapi siklus CRUD dengan Delete (Hapus).
Karena database kita menggunakan ON DELETE CASCADE di skema sebelumnya, menghapus Post (Induk) akan otomatis menghapus semua Images (Anak) yang terkait. Jadi, kodingnya sangat simpel.
Namun, karena website ini akan online, kita harus hati-hati. Jangan sampai orang asing iseng menghapus data Anda.
Berikut adalah cara membuat fitur hapus dengan pengaman PIN sederhana.
Langkah 1: Tambahkan Tombol Hapus di Halaman Detail
Buka route app.get('/post/:id', ...) di src/index.ts.
Tambahkan tombol hapus di bagian bawah, misalnya di sebelah atau di bawah tombol "Lihat Karya Lainnya".
Cari bagian ``, dan ubah menjadi seperti ini:
TypeScript
<div class="mt-12 pt-8 border-t border-gray-800 flex flex-col items-center gap-4">
<a href="/" class="px-6 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-white font-bold transition">
← Kembali ke Home
</a>
<form action="/delete/${post.id}" method="POST" class="flex gap-2 items-center mt-4" onsubmit="return confirm('Yakin ingin menghapus postingan ini selamanya?')">
<input type="password" name="pin" placeholder="PIN Admin" class="bg-gray-900 border border-red-900 text-red-500 text-sm p-2 rounded w-24 focus:outline-none focus:border-red-500" required>
<button type="submit" class="text-sm bg-red-900/30 hover:bg-red-900/50 text-red-400 border border-red-900 px-4 py-2 rounded transition">
🗑️ Hapus Post
</button>
</form>
</div>
Langkah 2: Buat Route Penghapus (/delete/:id)
Tambahkan kode ini di bagian paling bawah (sebelum export default app), mirip seperti saat menambahkan route detail tadi.
Logic-nya: Cek PIN dulu -> Kalau benar, Hapus Data -> Redirect ke Home.
TypeScript
// --- ROUTE: DELETE POST ---
app.post('/delete/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.parseBody();
const pin = body['pin'];
// 1. Cek Keamanan Sederhana
// Ganti '1234' dengan PIN rahasia yang Anda inginkan
if (pin !== '1234') {
return c.text('⛔ PIN Salah! Anda tidak berhak menghapus data ini.', 403);
}
// 2. Eksekusi Hapus (Cascade akan otomatis menghapus gambar di tabel images)
await c.env.DB.prepare('DELETE FROM posts WHERE id = ?').bind(id).run();
// 3. Kembali ke halaman utama
return c.redirect('/');
})
Penjelasan Kode
- Form
POST: Kita menggunakan method POST (bukan GET/Link biasa) agar aman dari klik tidak sengaja oleh bot/crawler Google. onsubmit="return confirm(...)": Ini memunculkan popup browser "OK/Cancel" sebelum form dikirim. Pengaman lapis pertama.- PIN Sederhana: Di backend, saya set PIN-nya
1234. Anda bisa menggantinya dengan angka/kata sandi lain di dalam kodingan. Ini pengaman lapis kedua agar pengunjung web tidak bisa sembarangan menghapus.
Cara Menguji
- Save dan jalankan (
npm run dev). - Buka salah satu gambar.
- Scroll ke paling bawah.
- Coba isi PIN asal-asalan -> Klik Hapus -> Harusnya Gagal.
- Isi PIN
1234-> Klik Hapus -> Data hilang dan kembali ke Home.
Apakah fitur ini sudah cukup aman untuk kebutuhan belajar Anda, atau Anda ingin mencoba membuat sistem Login Admin (Username & Password) yang lebih serius?
Tentu, mari kita bahas satu per satu. Pertanyaan Anda sangat relevan untuk fase "scaling up" (pengembangan) website.
1. Apakah Mendukung SEO? (Jawabannya: SANGAT YA)
Setup Anda saat ini (Hono + Cloudflare Workers) menggunakan teknik SSR (Server-Side Rendering).
- Keunggulan: Saat Google Bot atau crawler media sosial (WhatsApp/Twitter/Facebook) membuka link Anda, mereka langsung menerima HTML lengkap (bukan halaman kosong yang menunggu JavaScript loading seperti di React/Vue biasa).
Peningkatan SEO:Agar lebih maksimal, Anda perlu menambahkan Meta Tags dinamis di bagian <head> pada file src/index.ts.Cukup update fungsi Layout Anda sedikit untuk menerima deskripsi dan gambar untuk preview sosmed:TypeScript
// Di fungsi Layout
const Layout = (content: any, title: string = "AI Art Gallery", desc: string = "Koleksi AI Art terbaik", image: string = "") => html`
<head>
<title>${title}</title>
<meta name="description" content="${desc}">
<meta property="og:type" content="website">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${desc}">
<meta property="og:image" content="${image}"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:image" content="${image}">
...
`
Nanti di route /post/:id, Anda tinggal isi parameternya dengan data dari database (post.title, post.tags, images[0].url).
2. Solusi Bulk Upload CSV (Tanpa Copy-Paste)
Anda benar, copy-paste satu per satu itu sangat melelahkan. Membuat fitur upload CSV di dashboard web itu bisa, tapi cukup rumit coding-nya (harus handle upload file + parsing CSV di server).
Solusi Cerdas untuk Developer:
Karena Anda punya akses ke komputer lokal dan terminal, cara tercepat adalah "Generate SQL di Lokal".
Alurnya:
- Siapkan data di Excel/CSV.
- Gunakan script kecil (Python) untuk mengubah CSV menjadi file
.sql. - Upload file SQL tersebut sekali jalan menggunakan
wrangler.
A. Persiapan Data (CSV)
Buat file data_saya.csv. Pastikan kolomnya jelas, misal:
Judul,Kategori,Tags,URL_Gambar_1,URL_Gambar_2
Contoh isi:
Code snippet
Robot Kucing,Sci-Fi,"cat, robot, metal",https://i.imgur.com/A.jpg,https://i.imgur.com/B.jpg
Pemandangan Mars,Landscape,"red, mars, dust",https://i.imgur.com/C.jpg,
Catatan tentang Gambar Lokal:
D1 Database hanya menyimpan Teks (URL), bukan file gambarnya.
- Jika gambar masih di folder komputer, Anda harus upload dulu ke hosting.
- Saran: Anda bisa gunakan Cloudflare R2 (mirip Google Drive tapi untuk developer). Gratis 10GB penyimpanan. Anda bisa drag-and-drop ribuan gambar ke dashboard R2, lalu dapat URL-nya.
B. Script Konversi (Python Generator)
Karena Anda pernah bertanya tentang Python, ini cara paling ampuh. Simpan script ini sebagai convert.py di folder yang sama dengan CSV Anda.
Script ini akan membaca CSV dan membuat file bulk_import.sql.
Python
import csv
# Konfigurasi
input_csv = 'data_saya.csv'
output_sql = 'bulk_import.sql'
sql_statements = []
print("Mulai konversi...")
with open(input_csv, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
next(reader) # Lewati header baris pertama (Judul, Kategori, dll)
for row in reader:
# Ambil data dari kolom CSV
title = row[0].replace("'", "''") # Escape tanda petik untuk SQL
category = row[1]
tags = row[2]
# Ambil semua URL (kolom ke-3 sampai habis) dan bersihkan yang kosong
urls = [url.strip() for url in row[3:] if url.strip()]
if not urls:
continue # Skip jika tidak ada URL
# 1. Query Insert POST
# Kita pakai trik SQL Variable agar ID-nya nyambung otomatis
sql_statements.append(f"-- Post: {title}")
sql_statements.append(f"INSERT INTO posts (title, category, tags) VALUES ('{title}', '{category}', '{tags}');")
# 2. Query Insert IMAGES
# last_insert_rowid() mengambil ID dari post yang baru saja dibuat di atas
for i, url in enumerate(urls):
is_thumb = 1 if i == 0 else 0
sql_statements.append(f"INSERT INTO images (post_id, url, is_thumbnail) VALUES (last_insert_rowid(), '{url}', {is_thumb});")
# Tulis ke file .sql
with open(output_sql, 'w', encoding='utf-8') as f:
f.write("BEGIN TRANSACTION;\n") # Biar ngebut dan aman
f.write("\n".join(sql_statements))
f.write("\nCOMMIT;")
print(f"Selesai! File '{output_sql}' siap di-upload.")
C. Eksekusi Upload
Setelah file bulk_import.sql terbentuk (isinya ratusan baris INSERT INTO...), Anda tinggal kirim ke Cloudflare dengan satu perintah sakti:
Untuk Live (Production):Bash
npx wrangler d1 execute prod-ai-blog-db --remote --file=bulk_import.sql
Untuk Test Lokal:Bash
npx wrangler d1 execute prod-ai-blog-db --local --file=bulk_import.sql
Kenapa cara ini lebih baik daripada Dashboard Admin?
- Tanpa Batas: Browser biasanya timeout jika upload ribuan data via web dashboard. CLI
wranglersangat stabil. - Aman: Anda tidak perlu membuat sistem login yang rumit di website hanya untuk upload data.
- Fleksibel: Anda bisa mengedit data di Excel sepuasnya sebelum dikonversi.
Apakah alur kerja "Excel -> Python -> Wrangler" ini masuk akal untuk kebutuhan Anda?