๐ Roadmap Deployment
Part 1 (Sekarang): Foundation – MySQL + Auth + Core Models
Part 2: Admin Dashboard + Upload + Stats
Part 3: Frontend – Dark Mode + Share + Video + Comments
Part 4: SEO + RSS + Popular Tags + Pin Post
๐๏ธ STEP 1: Setup MySQL Database
Login ke Hostinger โ Databases โ MySQL โ Buat database baru. Catat:
DB_HOST(biasanyalocalhost)DB_NAMEDB_USERDB_PASSWORD
Import SQL ini via phpMyAdmin:
File: database.sql
-- Users (admin)
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
email VARCHAR(100),
role ENUM('admin','editor') DEFAULT 'admin',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Posts
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
content LONGTEXT NOT NULL,
excerpt TEXT,
category VARCHAR(50) DEFAULT 'Lainnya',
thumbnail VARCHAR(500),
video_url VARCHAR(500),
video_type ENUM('youtube','mp4','hls','none') DEFAULT 'none',
status ENUM('published','draft','private') DEFAULT 'draft',
is_featured BOOLEAN DEFAULT FALSE,
views INT DEFAULT 0,
meta_title VARCHAR(255),
meta_description TEXT,
meta_keywords VARCHAR(500),
author_id INT,
published_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES users(id),
INDEX idx_status (status),
INDEX idx_slug (slug),
INDEX idx_category (category),
INDEX idx_featured (is_featured)
);
-- Tags
CREATE TABLE tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL
);
-- Post-Tags relation
CREATE TABLE post_tags (
post_id INT,
tag_id INT,
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
-- Comments
CREATE TABLE comments (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
email VARCHAR(100),
content TEXT NOT NULL,
parent_id INT NULL,
is_approved BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
);
-- Views tracking (untuk chart)
CREATE TABLE post_views (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
ip_address VARCHAR(45),
viewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
INDEX idx_viewed_at (viewed_at)
);
-- Settings
CREATE TABLE settings (
`key` VARCHAR(50) PRIMARY KEY,
value TEXT
);
-- Default settings
INSERT INTO settings (`key`, value) VALUES
('site_title', 'My Blog Pro'),
('site_description', 'Koleksi tutorial, script & tips'),
('site_url', 'https://node.televisodes.com'),
('admin_email', 'admin@example.com');๐ฆ STEP 2: Package Dependencies
File: package.json
{
"name": "blog-pro",
"version": "3.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"setup": "node setup-admin.js"
},
"dependencies": {
"express": "^4.18.2",
"ejs": "^3.1.9",
"body-parser": "^1.20.2",
"mysql2": "^3.9.0",
"bcrypt": "^5.1.1",
"express-session": "^1.18.0",
"express-mysql-session": "^3.0.0",
"multer": "^1.4.5-lts.1",
"sharp": "^0.33.2",
"marked": "^12.0.0",
"highlight.js": "^11.9.0",
"dompurify": "^3.0.8",
"jsdom": "^24.0.0",
"slugify": "^1.6.6",
"rss": "^1.2.2",
"dotenv": "^16.3.1",
"cookie-parser": "^1.4.6",
"node-fetch": "^2.7.0"
}
}๐ STEP 3: Environment Variables
File: .env (TAMBAHIN DI HOSTINGER ENV VARS, JANGAN PUSH KE GITHUB!)
DB_HOST=localhost
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=your_db_name
SESSION_SECRET=random-super-secret-string-ganti-ini-123456789
NODE_ENV=production
PORT=3000File: .gitignore
node_modules/
.env
uploads/
*.log
.DS_Store๐๏ธ STEP 4: Project Structure
blog-pro/
โโโ .env
โโโ .gitignore
โโโ package.json
โโโ index.js
โโโ setup-admin.js
โโโ database.sql
โโโ config/
โ โโโ db.js
โโโ middleware/
โ โโโ auth.js
โโโ routes/
โ โโโ public.js
โ โโโ admin.js
โ โโโ auth.js
โ โโโ api.js
โโโ utils/
โ โโโ markdown.js
โ โโโ helpers.js
โโโ views/
โ โโโ layouts/
โ โ โโโ public.ejs
โ โ โโโ admin.ejs
โ โโโ public/
โ โ โโโ home.ejs
โ โ โโโ post.ejs
โ โ โโโ tag.ejs
โ โโโ admin/
โ โ โโโ login.ejs
โ โ โโโ dashboard.ejs
โ โ โโโ posts.ejs
โ โ โโโ post-editor.ejs
โ โ โโโ comments.ejs
โ โ โโโ settings.ejs
โ โโโ 404.ejs
โโโ public/
โ โโโ css/
โ โ โโโ style.css
โ โ โโโ admin.css
โ โโโ js/
โ โ โโโ main.js
โ โโโ uploads/๐ง STEP 5: Core Files
config/db.js
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
module.exports = pool;
setup-admin.js(Run sekali buat bikin admin pertama)
const bcrypt = require('bcrypt');
const db = require('./config/db');
const readline = require('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) => new Promise(r => rl.question(q, r));
(async () => {
try {
const username = await ask('Username admin: ');
const email = await ask('Email: ');
const password = await ask('Password: ');
const hash = await bcrypt.hash(password, 10);
await db.query(
'INSERT INTO users (username, password, email, role) VALUES (?, ?, ?, ?)',
[username, hash, email, 'admin']
);
console.log('โ
Admin berhasil dibuat!');
process.exit(0);
} catch (e) {
console.error('โ Error:', e.message);
process.exit(1);
}
})();middleware/auth.js
exports.requireAuth = (req, res, next) => {
if (!req.session.user) {
return res.redirect('/admin/login');
}
next();
};
exports.redirectIfAuth = (req, res, next) => {
if (req.session.user) return res.redirect('/admin');
next();
};
exports.injectUser = (req, res, next) => {
res.locals.currentUser = req.session.user || null;
next();
};utils/markdown.js
const { marked } = require('marked');
const hljs = require('highlight.js');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
marked.setOptions({
highlight: (code, lang) => {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
breaks: true,
gfm: true
});
exports.render = (text) => {
if (!text) return '';
const html = marked.parse(text);
return DOMPurify.sanitize(html, {
ADD_ATTR: ['class', 'target'],
ADD_TAGS: ['code', 'pre', 'iframe', 'video', 'source'],
ALLOW_UNKNOWN_PROTOCOLS: false
});
};
exports.stripMarkdown = (text) => {
if (!text) return '';
return text
.replace(/```[\s\S]*?```/g, '')
.replace(/`([^`]+)`/g, '$1')
.replace(/[#*_~>\$\$()]/g, '')
.replace(/!\$.*?\$\$.*?\$/g, '')
.replace(/\n+/g, ' ')
.trim();
};utils/helpers.js
const slugify = require('slugify');
exports.makeSlug = (text) => slugify(text, { lower: true, strict: true });
exports.readingTime = (text) => {
const words = text.replace(/[#*`_~]/g, '').split(/\s+/).length;
return Math.max(1, Math.ceil(words / 200));
};
exports.formatDate = (date) => {
return new Date(date).toLocaleString('id-ID', {
day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
exports.detectVideoType = (url) => {
if (!url) return 'none';
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
if (url.includes('.m3u8')) return 'hls';
if (url.match(/\.(mp4|webm|ogg)$/i)) return 'mp4';
return 'none';
};
exports.getYoutubeId = (url) => {
const match = url.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/);
return match ? match[1] : null;
};
exports.getThumbnail = (post) => {
if (post.thumbnail) return post.thumbnail;
const colors = {
'Tutorial': '4F46E5', 'Script': '059669', 'Tips & Trick': 'DC2626',
'AI Prompt': '7C3AED', 'Review': 'EA580C', 'Lainnya': '6B7280'
};
const color = colors[post.category] || '6B7280';
const text = encodeURIComponent((post.title || '').substring(0, 30));
return `https://placehold.co/600x300/${color}/white?text=${text}`;
};index.js (Main App)
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const MySQLStore = require('express-mysql-session')(session);
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
// Session store di MySQL
const sessionStore = new MySQLStore({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
});
// View engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Middleware
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(path.join(__dirname, 'public/uploads')));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(cookieParser());
app.use(session({
key: 'blog_session',
secret: process.env.SESSION_SECRET,
store: sessionStore,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7, // 1 minggu
httpOnly: true,
secure: process.env.NODE_ENV === 'production'
}
}));
// Inject user to all views
const { injectUser } = require('./middleware/auth');
app.use(injectUser);
// Inject theme
app.use((req, res, next) => {
res.locals.theme = req.cookies.theme || 'light';
next();
});
// Routes
app.use('/', require('./routes/public'));
app.use('/admin', require('./routes/auth'));
app.use('/admin', require('./routes/admin'));
app.use('/api', require('./routes/api'));
// 404
app.use((req, res) => {
res.status(404).render('404');
});
// Error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(500).send('Server error');
});
app.listen(PORT, () => {
console.log(`๐ Blog running on port ${PORT}`);
});routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const db = require('../config/db');
const { redirectIfAuth } = require('../middleware/auth');
const router = express.Router();
// ============ LOGIN ============
router.get('/login', redirectIfAuth, (req, res) => {
res.render('admin/login', { error: null });
});
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const [users] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
if (users.length === 0) {
return res.render('admin/login', { error: 'Username tidak ditemukan' });
}
const user = users[0];
let match = false;
// Support both plain text & bcrypt hash (temporary)
if (user.password.startsWith('$2')) {
match = await bcrypt.compare(password, user.password);
} else {
match = (password === user.password);
// Auto-upgrade ke bcrypt setelah login sukses
if (match) {
const hash = await bcrypt.hash(password, 10);
await db.query('UPDATE users SET password = ? WHERE id = ?', [hash, user.id]);
}
}
if (!match) {
return res.render('admin/login', { error: 'Password salah' });
}
req.session.user = {
id: user.id,
username: user.username,
email: user.email,
role: user.role
};
res.redirect('/admin');
} catch (e) {
console.error(e);
res.render('admin/login', { error: 'Error: ' + e.message });
}
});
// ============ SETUP ADMIN (TEMPORARY - HAPUS SETELAH DIPAKAI!) ============
router.get('/setup-xyz-123-secret', async (req, res) => {
try {
const [existing] = await db.query('SELECT COUNT(*) as count FROM users');
if (existing[0].count > 0) {
return res.send(`
<h1>โน๏ธ Admin sudah ada (${existing[0].count} user)</h1>
<p><a href="/admin/login">Go to Login</a></p>
<p>Reset password? <a href="/admin/setup-reset-xyz-123">Click here</a></p>
`);
}
await db.query(
'INSERT INTO users (username, password, email, role) VALUES (?, ?, ?, ?)',
['admin', 'admin123', 'admin@televisodes.com', 'admin']
);
res.send(`
<h1>โ
Admin Created!</h1>
<p><strong>Username:</strong> admin</p>
<p><strong>Password:</strong> admin123</p>
<p><a href="/admin/login">โ LOGIN NOW</a></p>
<p style="color:red;"><strong>โ ๏ธ HAPUS route ini setelah login!</strong></p>
`);
} catch (e) {
res.send('<pre>Error: ' + e.message + '</pre>');
}
});
// ============ RESET PASSWORD (TEMPORARY) ============
router.get('/setup-reset-xyz-123', async (req, res) => {
try {
await db.query("UPDATE users SET password = 'admin123' WHERE username = 'admin'");
res.send(`
<h1>๐ Password Reset!</h1>
<p><strong>Username:</strong> admin</p>
<p><strong>Password:</strong> admin123</p>
<p><a href="/admin/login">โ LOGIN NOW</a></p>
`);
} catch (e) {
res.send('Error: ' + e.message);
}
});
// ============ LOGOUT ============
router.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/admin/login'));
});
module.exports = router;routes/public.js
const express = require('express');
const db = require('../config/db');
const markdown = require('../utils/markdown');
const helpers = require('../utils/helpers');
const RSS = require('rss');
const router = express.Router();
// Homepage
router.get('/', async (req, res) => {
try {
const { q, category, page = 1 } = req.query;
const limit = 12;
const offset = (page - 1) * limit;
let sql = `SELECT p.*, GROUP_CONCAT(t.name) as tag_names
FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.status = 'published'`;
const params = [];
if (category && category !== 'all') {
sql += ' AND p.category = ?';
params.push(category);
}
if (q) {
sql += ' AND (p.title LIKE ? OR p.content LIKE ?)';
params.push(`%${q}%`, `%${q}%`);
}
sql += ' GROUP BY p.id ORDER BY p.is_featured DESC, p.published_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const [posts] = await db.query(sql, params);
// Featured posts (pinned)
const [featured] = await db.query(
`SELECT * FROM posts WHERE status='published' AND is_featured=1 ORDER BY published_at DESC LIMIT 3`
);
// Popular tags
const [popularTags] = await db.query(`
SELECT t.name, t.slug, COUNT(pt.post_id) as count
FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
JOIN posts p ON pt.post_id = p.id
WHERE p.status = 'published'
GROUP BY t.id
ORDER BY count DESC
LIMIT 15
`);
// Categories
const [categories] = await db.query(`
SELECT category, COUNT(*) as count
FROM posts
WHERE status='published'
GROUP BY category
`);
// Site settings
const [settings] = await db.query('SELECT * FROM settings');
const site = {};
settings.forEach(s => site[s.key] = s.value);
const enriched = posts.map(p => ({
...p,
tags: p.tag_names ? p.tag_names.split(',') : [],
excerpt: p.excerpt || markdown.stripMarkdown(p.content).substring(0, 150),
readTime: helpers.readingTime(p.content),
thumbnail: helpers.getThumbnail(p),
formattedDate: helpers.formatDate(p.published_at || p.created_at)
}));
res.render('public/home', {
posts: enriched,
featured: featured.map(p => ({ ...p, thumbnail: helpers.getThumbnail(p) })),
popularTags,
categories,
site,
currentCategory: category || 'all',
searchQuery: q || '',
currentPage: parseInt(page)
});
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
// Single post
router.get('/post/:slug', async (req, res) => {
try {
const [posts] = await db.query(
`SELECT p.*, u.username as author_name
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
WHERE p.slug = ? AND p.status IN ('published', 'private')`,
[req.params.slug]
);
if (posts.length === 0) return res.status(404).render('404');
const post = posts[0];
// Block private posts jika bukan admin
if (post.status === 'private' && !req.session.user) {
return res.status(404).render('404');
}
// Track view
const ip = req.ip;
await db.query('UPDATE posts SET views = views + 1 WHERE id = ?', [post.id]);
await db.query('INSERT INTO post_views (post_id, ip_address) VALUES (?, ?)', [post.id, ip]);
// Get tags
const [tags] = await db.query(`
SELECT t.* FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
WHERE pt.post_id = ?
`, [post.id]);
// Comments
const [comments] = await db.query(
'SELECT * FROM comments WHERE post_id = ? AND is_approved = 1 AND parent_id IS NULL ORDER BY created_at DESC',
[post.id]
);
// Replies
for (let c of comments) {
const [replies] = await db.query(
'SELECT * FROM comments WHERE parent_id = ? AND is_approved = 1 ORDER BY created_at ASC',
[c.id]
);
c.replies = replies;
}
// Related
const [related] = await db.query(
`SELECT * FROM posts WHERE category = ? AND id != ? AND status = 'published' LIMIT 3`,
[post.category, post.id]
);
// Settings
const [settings] = await db.query('SELECT * FROM settings');
const site = {};
settings.forEach(s => site[s.key] = s.value);
res.render('public/post', {
post,
tags,
comments,
related: related.map(p => ({ ...p, thumbnail: helpers.getThumbnail(p) })),
renderedContent: markdown.render(post.content),
readTime: helpers.readingTime(post.content),
thumbnail: helpers.getThumbnail(post),
videoType: post.video_type,
youtubeId: post.video_type === 'youtube' ? helpers.getYoutubeId(post.video_url) : null,
site,
formattedDate: helpers.formatDate(post.published_at || post.created_at),
shareUrl: `${site.site_url}/post/${post.slug}`
});
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
// Post comment
router.post('/post/:slug/comment', async (req, res) => {
try {
const { name, email, content, parent_id } = req.body;
const [posts] = await db.query('SELECT id FROM posts WHERE slug = ?', [req.params.slug]);
if (posts.length === 0) return res.redirect('/');
await db.query(
'INSERT INTO comments (post_id, name, email, content, parent_id) VALUES (?, ?, ?, ?, ?)',
[posts[0].id, name, email, content, parent_id || null]
);
res.redirect(`/post/${req.params.slug}#comments`);
} catch (e) {
console.error(e);
res.redirect('/');
}
});
// Tag filter
router.get('/tag/:slug', async (req, res) => {
try {
const [tags] = await db.query('SELECT * FROM tags WHERE slug = ?', [req.params.slug]);
if (tags.length === 0) return res.status(404).render('404');
const [posts] = await db.query(`
SELECT p.* FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
WHERE pt.tag_id = ? AND p.status = 'published'
ORDER BY p.published_at DESC
`, [tags[0].id]);
const [settings] = await db.query('SELECT * FROM settings');
const site = {};
settings.forEach(s => site[s.key] = s.value);
const enriched = posts.map(p => ({
...p,
excerpt: markdown.stripMarkdown(p.content).substring(0, 150),
readTime: helpers.readingTime(p.content),
thumbnail: helpers.getThumbnail(p),
formattedDate: helpers.formatDate(p.published_at || p.created_at)
}));
res.render('public/tag', { posts: enriched, tag: tags[0], site });
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
// RSS feed
router.get('/rss.xml', async (req, res) => {
try {
const [settings] = await db.query('SELECT * FROM settings');
const site = {};
settings.forEach(s => site[s.key] = s.value);
const feed = new RSS({
title: site.site_title,
description: site.site_description,
feed_url: `${site.site_url}/rss.xml`,
site_url: site.site_url,
language: 'id'
});
const [posts] = await db.query(
`SELECT * FROM posts WHERE status = 'published' ORDER BY published_at DESC LIMIT 20`
);
posts.forEach(p => {
feed.item({
title: p.title,
description: p.excerpt || markdown.stripMarkdown(p.content).substring(0, 300),
url: `${site.site_url}/post/${p.slug}`,
date: p.published_at || p.created_at,
categories: [p.category]
});
});
res.set('Content-Type', 'application/rss+xml');
res.send(feed.xml({ indent: true }));
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
// Sitemap
router.get('/sitemap.xml', async (req, res) => {
try {
const [settings] = await db.query('SELECT * FROM settings');
const site = {};
settings.forEach(s => site[s.key] = s.value);
const [posts] = await db.query(`SELECT slug, updated_at FROM posts WHERE status='published'`);
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
xml += `<url><loc>${site.site_url}</loc></url>\n`;
posts.forEach(p => {
xml += `<url><loc>${site.site_url}/post/${p.slug}</loc><lastmod>${new Date(p.updated_at).toISOString()}</lastmod></url>\n`;
});
xml += `</urlset>`;
res.set('Content-Type', 'application/xml');
res.send(xml);
} catch (e) {
res.status(500).send('Error');
}
});
// Theme toggle
router.post('/theme', (req, res) => {
const { theme } = req.body;
res.cookie('theme', theme, { maxAge: 1000 * 60 * 60 * 24 * 365 });
res.json({ success: true });
});
module.exports = router;routes/admin.js
const express = require('express');
const db = require('../config/db');
const multer = require('multer');
const sharp = require('sharp');
const fetch = require('node-fetch');
const path = require('path');
const fs = require('fs');
const { requireAuth } = require('../middleware/auth');
const helpers = require('../utils/helpers');
const markdown = require('../utils/markdown');
const router = express.Router();
// Upload config
const uploadDir = path.join(__dirname, '../public/uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.memoryStorage();
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
});
// ========== DASHBOARD ==========
router.get('/', requireAuth, async (req, res) => {
try {
const [[stats]] = await db.query(`
SELECT
(SELECT COUNT(*) FROM posts WHERE status='published') as published,
(SELECT COUNT(*) FROM posts WHERE status='draft') as drafts,
(SELECT COUNT(*) FROM posts WHERE status='private') as privates,
(SELECT SUM(views) FROM posts) as total_views,
(SELECT COUNT(*) FROM comments) as total_comments
`);
// Top posts
const [topPosts] = await db.query(
`SELECT id, title, slug, views FROM posts WHERE status='published' ORDER BY views DESC LIMIT 5`
);
// Recent posts
const [recentPosts] = await db.query(
`SELECT id, title, slug, status, views, created_at FROM posts ORDER BY created_at DESC LIMIT 5`
);
// Views last 7 days
const [viewsChart] = await db.query(`
SELECT DATE(viewed_at) as date, COUNT(*) as count
FROM post_views
WHERE viewed_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY DATE(viewed_at)
ORDER BY date ASC
`);
res.render('admin/dashboard', { stats, topPosts, recentPosts, viewsChart });
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
// ========== POSTS LIST ==========
router.get('/posts', requireAuth, async (req, res) => {
try {
const { status } = req.query;
let sql = 'SELECT * FROM posts';
const params = [];
if (status && status !== 'all') {
sql += ' WHERE status = ?';
params.push(status);
}
sql += ' ORDER BY created_at DESC';
const [posts] = await db.query(sql, params);
res.render('admin/posts', { posts, currentStatus: status || 'all', helpers });
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
// ========== NEW POST ==========
router.get('/posts/new', requireAuth, (req, res) => {
res.render('admin/post-editor', { post: null, tags: [] });
});
router.post('/posts/new', requireAuth, async (req, res) => {
try {
const { title, content, category, thumbnail, video_url, status, is_featured,
tags, meta_title, meta_description, meta_keywords, excerpt } = req.body;
const slug = helpers.makeSlug(title) + '-' + Date.now().toString(36);
const video_type = helpers.detectVideoType(video_url);
const published_at = status === 'published' ? new Date() : null;
const [result] = await db.query(`
INSERT INTO posts
(title, slug, content, excerpt, category, thumbnail, video_url, video_type,
status, is_featured, meta_title, meta_description, meta_keywords,
author_id, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [title, slug, content, excerpt || markdown.stripMarkdown(content).substring(0, 200),
category, thumbnail, video_url, video_type, status, is_featured ? 1 : 0,
meta_title, meta_description, meta_keywords, req.session.user.id, published_at]);
const postId = result.insertId;
// Handle tags
if (tags) {
const tagList = tags.split(',').map(t => t.trim()).filter(Boolean);
for (const tagName of tagList) {
const tagSlug = helpers.makeSlug(tagName);
await db.query('INSERT IGNORE INTO tags (name, slug) VALUES (?, ?)', [tagName, tagSlug]);
const [tag] = await db.query('SELECT id FROM tags WHERE slug = ?', [tagSlug]);
await db.query('INSERT IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)', [postId, tag[0].id]);
}
}
res.redirect('/admin/posts');
} catch (e) {
console.error(e);
res.status(500).send('Error: ' + e.message);
}
});
// ========== EDIT POST ==========
router.get('/posts/edit/:id', requireAuth, async (req, res) => {
try {
const [posts] = await db.query('SELECT * FROM posts WHERE id = ?', [req.params.id]);
if (posts.length === 0) return res.status(404).send('Not found');
const [tags] = await db.query(`
SELECT t.name FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
WHERE pt.post_id = ?
`, [req.params.id]);
const post = posts[0];
post.tagsString = tags.map(t => t.name).join(', ');
res.render('admin/post-editor', { post, tags });
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
router.post('/posts/edit/:id', requireAuth, async (req, res) => {
try {
const { title, content, category, thumbnail, video_url, status, is_featured,
tags, meta_title, meta_description, meta_keywords, excerpt } = req.body;
const video_type = helpers.detectVideoType(video_url);
// Cek apakah perlu set published_at
const [current] = await db.query('SELECT status, published_at FROM posts WHERE id = ?', [req.params.id]);
let published_at = current[0].published_at;
if (status === 'published' && !published_at) {
published_at = new Date();
}
await db.query(`
UPDATE posts SET
title=?, content=?, excerpt=?, category=?, thumbnail=?, video_url=?, video_type=?,
status=?, is_featured=?, meta_title=?, meta_description=?, meta_keywords=?, published_at=?
WHERE id=?
`, [title, content, excerpt || markdown.stripMarkdown(content).substring(0, 200),
category, thumbnail, video_url, video_type, status, is_featured ? 1 : 0,
meta_title, meta_description, meta_keywords, published_at, req.params.id]);
// Update tags - hapus dulu, tambah ulang
await db.query('DELETE FROM post_tags WHERE post_id = ?', [req.params.id]);
if (tags) {
const tagList = tags.split(',').map(t => t.trim()).filter(Boolean);
for (const tagName of tagList) {
const tagSlug = helpers.makeSlug(tagName);
await db.query('INSERT IGNORE INTO tags (name, slug) VALUES (?, ?)', [tagName, tagSlug]);
const [tag] = await db.query('SELECT id FROM tags WHERE slug = ?', [tagSlug]);
await db.query('INSERT IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)', [req.params.id, tag[0].id]);
}
}
res.redirect('/admin/posts');
} catch (e) {
console.error(e);
res.status(500).send('Error: ' + e.message);
}
});
// Quick status change
router.post('/posts/:id/status', requireAuth, async (req, res) => {
try {
const { status } = req.body;
const published_at = status === 'published' ? new Date() : null;
await db.query('UPDATE posts SET status = ?, published_at = COALESCE(published_at, ?) WHERE id = ?',
[status, published_at, req.params.id]);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Toggle feature
router.post('/posts/:id/feature', requireAuth, async (req, res) => {
try {
await db.query('UPDATE posts SET is_featured = NOT is_featured WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Delete post
router.post('/posts/:id/delete', requireAuth, async (req, res) => {
try {
await db.query('DELETE FROM posts WHERE id = ?', [req.params.id]);
res.redirect('/admin/posts');
} catch (e) {
console.error(e);
res.status(500).send('Error');
}
});
// ========== UPLOAD IMAGE ==========
router.post('/upload', requireAuth, upload.single('image'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file' });
const filename = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.webp`;
const filepath = path.join(uploadDir, filename);
await sharp(req.file.buffer)
.resize(1200, null, { withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(filepath);
res.json({ url: `/uploads/${filename}` });
} catch (e) {
console.error(e);
res.status(500).json({ error: e.message });
}
});
// Upload from URL (remote GAS)
router.post('/upload-url', requireAuth, async (req, res) => {
try {
const { url } = req.body;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch image');
const buffer = await response.buffer();
const filename = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.webp`;
const filepath = path.join(uploadDir, filename);
await sharp(buffer)
.resize(1200, null, { withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(filepath);
res.json({ url: `/uploads/${filename}` });
} catch (e) {
console.error(e);
res.status(500).json({ error: e.message });
}
});
// ========== COMMENTS ==========
router.get('/comments', requireAuth, async (req, res) => {
try {
const [comments] = await db.query(`
SELECT c.*, p.title as post_title, p.slug as post_slug
FROM comments c
JOIN posts p ON c.post_id = p.id
ORDER BY c.created_at DESC
`);
res.render('admin/comments', { comments });
} catch (e) {
res.status(500).send('Error');
}
});
router.post('/comments/:id/delete', requireAuth, async (req, res) => {
await db.query('DELETE FROM comments WHERE id = ?', [req.params.id]);
res.redirect('/admin/comments');
});
router.post('/comments/:id/approve', requireAuth, async (req, res) => {
await db.query('UPDATE comments SET is_approved = NOT is_approved WHERE id = ?', [req.params.id]);
res.redirect('/admin/comments');
});
// ========== SETTINGS ==========
router.get('/settings', requireAuth, async (req, res) => {
const [settings] = await db.query('SELECT * FROM settings');
const site = {};
settings.forEach(s => site[s.key] = s.value);
res.render('admin/settings', { site });
});
router.post('/settings', requireAuth, async (req, res) => {
const { site_title, site_description, site_url, admin_email } = req.body;
const updates = { site_title, site_description, site_url, admin_email };
for (const [key, value] of Object.entries(updates)) {
await db.query('INSERT INTO settings (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?',
[key, value, value]);
}
res.redirect('/admin/settings');
});
module.exports = router;routes/api.js
const express = require('express');
const db = require('../config/db');
const router = express.Router();
// Search API (buat autocomplete)
router.get('/search', async (req, res) => {
const { q } = req.query;
if (!q || q.length < 2) return res.json([]);
const [posts] = await db.query(
`SELECT id, title, slug FROM posts WHERE status='published' AND title LIKE ? LIMIT 10`,
[`%${q}%`]
);
res.json(posts);
});
module.exports = router;โ FIX 2: Bikin File Views yang Hilang
views/
โโโ 404.ejs
โโโ layouts/
โ โโโ public.ejs
โ โโโ admin.ejs
โโโ public/
โ โโโ home.ejs
โ โโโ post.ejs
โ โโโ tag.ejs
โโโ admin/
โโโ login.ejs
โโโ dashboard.ejs
โโโ posts.ejs
โโโ post-editor.ejs
โโโ comments.ejs
โโโ settings.ejs๐ views/404.ejs
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>404 - Not Found</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container" style="text-align:center; padding:80px 20px;">
<h1 style="font-size:80px; margin:0;">404</h1>
<p style="font-size:20px; color:#888;">Halaman tidak ditemukan</p>
<a href="/" class="btn" style="margin-top:20px; display:inline-block;">โ Kembali ke Home</a>
</div>
</body>
</html>๐ views/admin/login.ejs
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Admin Login</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,sans-serif; }
body { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); min-height:100vh; display:flex; align-items:center; justify-content:center; }
.login-box { background:white; padding:40px; border-radius:12px; box-shadow:0 20px 60px rgba(0,0,0,0.3); width:100%; max-width:400px; }
h1 { margin-bottom:8px; color:#333; }
.subtitle { color:#888; margin-bottom:24px; font-size:14px; }
label { display:block; margin-bottom:6px; color:#444; font-size:13px; font-weight:600; }
input { width:100%; padding:12px; border:2px solid #eee; border-radius:6px; font-size:15px; margin-bottom:16px; }
input:focus { border-color:#667eea; outline:none; }
button { width:100%; padding:12px; background:#667eea; color:white; border:none; border-radius:6px; font-size:15px; font-weight:600; cursor:pointer; }
button:hover { background:#5568d3; }
.error { background:#fee; color:#c33; padding:10px; border-radius:6px; margin-bottom:16px; font-size:14px; }
</style>
</head>
<body>
<div class="login-box">
<h1>๐ Admin Login</h1>
<p class="subtitle">Masuk ke dashboard blog</p>
<% if (error) { %>
<div class="error"><%= error %></div>
<% } %>
<form action="/admin/login" method="POST">
<label>Username</label>
<input type="text" name="username" required autofocus>
<label>Password</label>
<input type="password" name="password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>๐ views/admin/dashboard.ejs
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
<link rel="stylesheet" href="/css/admin.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<%- include('../partials/admin-nav') %>
<div class="admin-container">
<h1>๐ Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Published</div>
<div class="stat-value"><%= stats.published || 0 %></div>
</div>
<div class="stat-card">
<div class="stat-label">Drafts</div>
<div class="stat-value"><%= stats.drafts || 0 %></div>
</div>
<div class="stat-card">
<div class="stat-label">Private</div>
<div class="stat-value"><%= stats.privates || 0 %></div>
</div>
<div class="stat-card">
<div class="stat-label">Total Views</div>
<div class="stat-value"><%= stats.total_views || 0 %></div>
</div>
<div class="stat-card">
<div class="stat-label">Comments</div>
<div class="stat-value"><%= stats.total_comments || 0 %></div>
</div>
</div>
<div class="dashboard-grid">
<div class="card">
<h2>๐ Views (7 Hari Terakhir)</h2>
<canvas id="viewsChart"></canvas>
</div>
<div class="card">
<h2>๐ฅ Top Posts</h2>
<ul class="simple-list">
<% topPosts.forEach(p => { %>
<li>
<a href="/post/<%= p.slug %>" target="_blank"><%= p.title %></a>
<span class="badge"><%= p.views %> views</span>
</li>
<% }) %>
</ul>
</div>
<div class="card">
<h2>๐ Recent Posts</h2>
<ul class="simple-list">
<% recentPosts.forEach(p => { %>
<li>
<a href="/admin/posts/edit/<%= p.id %>"><%= p.title %></a>
<span class="badge status-<%= p.status %>"><%= p.status %></span>
</li>
<% }) %>
</ul>
</div>
</div>
</div>
<script>
const ctx = document.getElementById('viewsChart');
const data = <%- JSON.stringify(viewsChart) %>;
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => new Date(d.date).toLocaleDateString('id-ID', {day:'numeric',month:'short'})),
datasets: [{
label: 'Views',
data: data.map(d => d.count),
borderColor: '#667eea',
backgroundColor: 'rgba(102,126,234,0.1)',
tension: 0.4,
fill: true
}]
},
options: { responsive: true, plugins: { legend: { display: false } } }
});
</script>
</body>
</html>๐ views/admin/posts.ejs
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Posts</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('../partials/admin-nav') %>
<div class="admin-container">
<div class="page-header">
<h1>๐ All Posts</h1>
<a href="/admin/posts/new" class="btn">+ New Post</a>
</div>
<div class="filter-tabs">
<a href="/admin/posts" class="tab <%= currentStatus === 'all' ? 'active' : '' %>">All</a>
<a href="/admin/posts?status=published" class="tab <%= currentStatus === 'published' ? 'active' : '' %>">Published</a>
<a href="/admin/posts?status=draft" class="tab <%= currentStatus === 'draft' ? 'active' : '' %>">Drafts</a>
<a href="/admin/posts?status=private" class="tab <%= currentStatus === 'private' ? 'active' : '' %>">Private</a>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Category</th>
<th>Status</th>
<th>Views</th>
<th>Featured</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% if (posts.length === 0) { %>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#888;">Belum ada post</td></tr>
<% } %>
<% posts.forEach(p => { %>
<tr>
<td>
<strong><%= p.title %></strong>
<% if (p.status === 'published') { %>
<a href="/post/<%= p.slug %>" target="_blank" style="margin-left:8px;font-size:12px;">โ</a>
<% } %>
</td>
<td><span class="badge"><%= p.category %></span></td>
<td><span class="badge status-<%= p.status %>"><%= p.status %></span></td>
<td><%= p.views %></td>
<td>
<form action="/admin/posts/<%= p.id %>/feature" method="POST" style="display:inline">
<button type="submit" class="icon-btn" title="Toggle feature">
<%= p.is_featured ? '๐' : '๐' %>
</button>
</form>
</td>
<td><%= new Date(p.created_at).toLocaleDateString('id-ID') %></td>
<td>
<a href="/admin/posts/edit/<%= p.id %>" class="btn-sm">Edit</a>
<form action="/admin/posts/<%= p.id %>/delete" method="POST" style="display:inline" onsubmit="return confirm('Hapus post ini?')">
<button type="submit" class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</body>
</html>โ๏ธ views/admin/post-editor.ejs
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title><%= post ? 'Edit' : 'New' %> Post</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('../partials/admin-nav') %>
<div class="admin-container">
<h1><%= post ? 'โ๏ธ Edit Post' : '๐ New Post' %></h1>
<form action="<%= post ? '/admin/posts/edit/' + post.id : '/admin/posts/new' %>" method="POST" class="editor-form">
<div class="editor-grid">
<div class="editor-main">
<label>Title</label>
<input type="text" name="title" required value="<%= post ? post.title : '' %>" placeholder="Judul post...">
<label>Content (Markdown)</label>
<div class="editor-toolbar">
<button type="button" onclick="uploadImage()">๐ท Upload Image</button>
<button type="button" onclick="uploadFromUrl()">๐ From URL</button>
<input type="file" id="imageUpload" accept="image/*" style="display:none">
</div>
<textarea name="content" id="content" rows="25" required placeholder="Tulis pake Markdown..."><%= post ? post.content : '' %></textarea>
<label>Excerpt (opsional)</label>
<textarea name="excerpt" rows="3" placeholder="Auto dari content kalau kosong"><%= post ? post.excerpt || '' : '' %></textarea>
</div>
<div class="editor-sidebar">
<div class="card">
<label>Status</label>
<select name="status">
<option value="draft" <%= post && post.status === 'draft' ? 'selected' : '' %>>๐ Draft</option>
<option value="published" <%= post && post.status === 'published' ? 'selected' : '' %>>โ
Published</option>
<option value="private" <%= post && post.status === 'private' ? 'selected' : '' %>>๐ Private</option>
</select>
<label style="margin-top:15px;">
<input type="checkbox" name="is_featured" value="1" <%= post && post.is_featured ? 'checked' : '' %>>
๐ Featured (Pin)
</label>
</div>
<div class="card">
<label>Category</label>
<select name="category">
<% const cats = ['Tutorial','Script','Tips & Trick','AI Prompt','Review','Lainnya']; %>
<% cats.forEach(c => { %>
<option value="<%= c %>" <%= post && post.category === c ? 'selected' : '' %>><%= c %></option>
<% }) %>
</select>
<label>Tags (koma)</label>
<input type="text" name="tags" value="<%= post && post.tagsString ? post.tagsString : '' %>" placeholder="nodejs, tutorial">
</div>
<div class="card">
<label>Thumbnail URL</label>
<input type="text" name="thumbnail" id="thumbnail" value="<%= post ? post.thumbnail || '' : '' %>" placeholder="/uploads/... atau https://">
<button type="button" onclick="uploadThumbnail()" class="btn-sm" style="margin-top:5px;">๐ท Upload Thumbnail</button>
<input type="file" id="thumbnailUpload" accept="image/*" style="display:none">
</div>
<div class="card">
<label>Video URL (YouTube/MP4/HLS)</label>
<input type="text" name="video_url" value="<%= post ? post.video_url || '' : '' %>" placeholder="https://youtube.com/... atau .mp4">
</div>
<div class="card">
<h3 style="font-size:14px;margin-bottom:10px;">๐ SEO</h3>
<label>Meta Title</label>
<input type="text" name="meta_title" value="<%= post ? post.meta_title || '' : '' %>">
<label>Meta Description</label>
<textarea name="meta_description" rows="3"><%= post ? post.meta_description || '' : '' %></textarea>
<label>Keywords</label>
<input type="text" name="meta_keywords" value="<%= post ? post.meta_keywords || '' : '' %>" placeholder="pisah, koma">
</div>
<button type="submit" class="btn btn-block">๐พ Save Post</button>
</div>
</div>
</form>
</div>
<script>
// Upload image inline
function uploadImage() {
document.getElementById('imageUpload').click();
}
document.getElementById('imageUpload').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/admin/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
const ta = document.getElementById('content');
ta.value += `\n\n\n`;
alert('โ
Uploaded!');
} else {
alert('โ Upload failed');
}
});
async function uploadFromUrl() {
const url = prompt('Paste image URL (termasuk dari Google Apps Script):');
if (!url) return;
const res = await fetch('/admin/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.url) {
const ta = document.getElementById('content');
ta.value += `\n\n\n`;
alert('โ
Uploaded!');
} else {
alert('โ Failed: ' + (data.error || 'unknown'));
}
}
function uploadThumbnail() {
document.getElementById('thumbnailUpload').click();
}
document.getElementById('thumbnailUpload').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/admin/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
document.getElementById('thumbnail').value = data.url;
alert('โ
Thumbnail set!');
}
});
</script>
</body>
</html>๐ฌ views/admin/comments.ejs
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Comments</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('../partials/admin-nav') %>
<div class="admin-container">
<h1>๐ฌ Comments</h1>
<table class="admin-table">
<thead>
<tr><th>Author</th><th>Content</th><th>Post</th><th>Status</th><th>Date</th><th>Actions</th></tr>
</thead>
<tbody>
<% if (comments.length === 0) { %>
<tr><td colspan="6" style="text-align:center;padding:40px;">No comments</td></tr>
<% } %>
<% comments.forEach(c => { %>
<tr>
<td><strong><%= c.name %></strong><br><small><%= c.email %></small></td>
<td style="max-width:300px;"><%= c.content %></td>
<td><a href="/post/<%= c.post_slug %>" target="_blank"><%= c.post_title %></a></td>
<td><span class="badge status-<%= c.is_approved ? 'published' : 'draft' %>"><%= c.is_approved ? 'Approved' : 'Pending' %></span></td>
<td><%= new Date(c.created_at).toLocaleDateString('id-ID') %></td>
<td>
<form action="/admin/comments/<%= c.id %>/approve" method="POST" style="display:inline">
<button type="submit" class="btn-sm"><%= c.is_approved ? 'Unapprove' : 'Approve' %></button>
</form>
<form action="/admin/comments/<%= c.id %>/delete" method="POST" style="display:inline" onsubmit="return confirm('Hapus?')">
<button type="submit" class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</body>
</html>โ๏ธ views/admin/settings.ejs
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Settings</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('../partials/admin-nav') %>
<div class="admin-container">
<h1>โ๏ธ Settings</h1>
<form action="/admin/settings" method="POST" style="max-width:600px;">
<div class="card">
<label>Site Title</label>
<input type="text" name="site_title" value="<%= site.site_title || '' %>" required>
<label>Site Description</label>
<textarea name="site_description" rows="3"><%= site.site_description || '' %></textarea>
<label>Site URL</label>
<input type="url" name="site_url" value="<%= site.site_url || '' %>" required>
<label>Admin Email</label>
<input type="email" name="admin_email" value="<%= site.admin_email || '' %>">
<button type="submit" class="btn" style="margin-top:20px;">๐พ Save</button>
</div>
</form>
</div>
</body>
</html>๐งญ views/partials/admin-nav.ejs
<nav class="admin-nav">
<div class="nav-brand">
<a href="/admin">โก Admin</a>
</div>
<div class="nav-links">
<a href="/admin">๐ Dashboard</a>
<a href="/admin/posts">๐ Posts</a>
<a href="/admin/comments">๐ฌ Comments</a>
<a href="/admin/settings">โ๏ธ Settings</a>
<a href="/" target="_blank">๐ View Site</a>
<a href="/admin/logout" class="logout">๐ช Logout</a>
</div>
</nav>๐ views/public/home.ejs
<!DOCTYPE html>
<html lang="id" data-theme="<%= theme %>">
<head>
<meta charset="UTF-8">
<title><%= site.site_title %></title>
<meta name="description" content="<%= site.site_description %>">
<link rel="stylesheet" href="/css/style.css">
<link rel="alternate" type="application/rss+xml" href="/rss.xml" title="RSS">
</head>
<body>
<div class="container">
<header class="main-header">
<div>
<h1>๐ <%= site.site_title %></h1>
<p class="subtitle"><%= site.site_description %></p>
</div>
<div class="header-actions">
<button onclick="toggleTheme()" class="theme-btn">๐</button>
<% if (currentUser) { %>
<a href="/admin" class="btn">Admin</a>
<% } %>
</div>
</header>
<form action="/" method="GET" class="search-form">
<input type="text" name="q" placeholder="๐ Cari..." value="<%= searchQuery %>" class="search-input">
<button type="submit" class="btn">Cari</button>
</form>
<div class="categories">
<a href="/" class="cat-pill <%= currentCategory === 'all' ? 'active' : '' %>">All</a>
<% categories.forEach(cat => { %>
<a href="/?category=<%= encodeURIComponent(cat.category) %>" class="cat-pill <%= currentCategory === cat.category ? 'active' : '' %>">
<%= cat.category %> (<%= cat.count %>)
</a>
<% }) %>
</div>
<% if (featured && featured.length > 0 && !searchQuery && currentCategory === 'all') { %>
<section class="featured-section">
<h2>๐ Featured</h2>
<div class="posts-grid">
<% featured.forEach(post => { %>
<article class="post-card featured">
<a href="/post/<%= post.slug %>" class="card-link">
<div class="thumbnail" style="background-image: url('<%= post.thumbnail %>')"></div>
<div class="card-body">
<span class="category-badge"><%= post.category %></span>
<h2><%= post.title %></h2>
</div>
</a>
</article>
<% }) %>
</div>
</section>
<% } %>
<div class="posts-grid">
<% if (posts.length === 0) { %>
<p class="empty">๐
Belum ada post.</p>
<% } %>
<% posts.forEach(post => { %>
<article class="post-card">
<a href="/post/<%= post.slug %>" class="card-link">
<div class="thumbnail" style="background-image: url('<%= post.thumbnail %>')"></div>
<div class="card-body">
<span class="category-badge"><%= post.category %></span>
<h2><%= post.title %></h2>
<p class="excerpt"><%= post.excerpt %>...</p>
<div class="meta">
<small>๐
<%= post.formattedDate %></small>
<small>โฑ <%= post.readTime %> min</small>
<small>๐ <%= post.views %></small>
</div>
</div>
</a>
</article>
<% }) %>
</div>
<% if (popularTags && popularTags.length > 0) { %>
<section class="tags-cloud">
<h3>๐ท Popular Tags</h3>
<div>
<% popularTags.forEach(t => { %>
<a href="/tag/<%= t.slug %>" class="tag">#<%= t.name %> (<%= t.count %>)</a>
<% }) %>
</div>
</section>
<% } %>
<footer class="footer">
<p>ยฉ <%= new Date().getFullYear() %> <%= site.site_title %> โข <a href="/rss.xml">RSS</a></p>
</footer>
</div>
<script>
function toggleTheme() {
const current = document.documentElement.dataset.theme;
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
fetch('/theme', { method: 'POST', headers: {'Content-Type':'application/x-www-form-urlencoded'}, body: `theme=${next}` });
}
</script>
</body>
</html>๐ views/public/post.ejs
<!DOCTYPE html>
<html lang="id" data-theme="<%= theme %>">
<head>
<meta charset="UTF-8">
<title><%= post.meta_title || post.title %></title>
<meta name="description" content="<%= post.meta_description || post.excerpt %>">
<meta name="keywords" content="<%= post.meta_keywords || '' %>">
<meta property="og:title" content="<%= post.title %>">
<meta property="og:description" content="<%= post.meta_description || post.excerpt %>">
<meta property="og:image" content="<%= thumbnail %>">
<meta property="og:url" content="<%= shareUrl %>">
<meta name="twitter:card" content="summary_large_image">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
<% if (videoType === 'hls') { %>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<% } %>
</head>
<body>
<div class="container">
<a href="/" class="back">โ Back</a>
<article class="post-detail">
<% if (videoType === 'youtube' && youtubeId) { %>
<div class="video-wrap">
<iframe src="https://www.youtube.com/embed/<%= youtubeId %>" frameborder="0" allowfullscreen></iframe>
</div>
<% } else if (videoType === 'mp4') { %>
<video controls class="post-video" poster="<%= thumbnail %>">
<source src="<%= post.video_url %>" type="video/mp4">
</video>
<% } else if (videoType === 'hls') { %>
<video id="hlsVideo" controls class="post-video" poster="<%= thumbnail %>"></video>
<script>
const video = document.getElementById('hlsVideo');
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource('<%= post.video_url %>');
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = '<%= post.video_url %>';
}
</script>
<% } else { %>
<div class="post-thumbnail" style="background-image: url('<%= thumbnail %>')"></div>
<% } %>
<div class="post-meta-top">
<span class="category-badge"><%= post.category %></span>
<% if (post.status === 'private') { %><span class="badge status-private">๐ Private</span><% } %>
</div>
<h1><%= post.title %></h1>
<div class="post-meta">
<small>๐
<%= formattedDate %></small>
<small>โฑ <%= readTime %> min</small>
<small>๐ <%= post.views %></small>
</div>
<% if (tags && tags.length > 0) { %>
<div class="tags tags-top">
<% tags.forEach(t => { %>
<a href="/tag/<%= t.slug %>" class="tag">#<%= t.name %></a>
<% }) %>
</div>
<% } %>
<div class="share-buttons">
<a href="https://twitter.com/intent/tweet?url=<%= encodeURIComponent(shareUrl) %>&text=<%= encodeURIComponent(post.title) %>" target="_blank" class="share-btn twitter">๐ Twitter</a>
<a href="https://t.me/share/url?url=<%= encodeURIComponent(shareUrl) %>&text=<%= encodeURIComponent(post.title) %>" target="_blank" class="share-btn telegram">โ Telegram</a>
<a href="https://reddit.com/submit?url=<%= encodeURIComponent(shareUrl) %>&title=<%= encodeURIComponent(post.title) %>" target="_blank" class="share-btn reddit">๐ฅ Reddit</a>
<button onclick="navigator.clipboard.writeText('<%= shareUrl %>').then(()=>alert('Link copied!'))" class="share-btn copy">๐ Copy</button>
</div>
<div class="markdown-body">
<%- renderedContent %>
</div>
</article>
<% if (related && related.length > 0) { %>
<section class="related">
<h2>๐ Related</h2>
<div class="posts-grid">
<% related.forEach(p => { %>
<article class="post-card">
<a href="/post/<%= p.slug %>" class="card-link">
<div class="thumbnail" style="background-image: url('<%= p.thumbnail %>')"></div>
<div class="card-body">
<span class="category-badge"><%= p.category %></span>
<h3><%= p.title %></h3>
</div>
</a>
</article>
<% }) %>
</div>
</section>
<% } %>
<section id="comments" class="comments-section">
<h2>๐ฌ Comments (<%= comments.length %>)</h2>
<form action="/post/<%= post.slug %>/comment" method="POST" class="comment-form">
<input type="text" name="name" placeholder="Nama" required>
<input type="email" name="email" placeholder="Email (opsional)">
<textarea name="content" placeholder="Komentar..." rows="4" required></textarea>
<button type="submit" class="btn">Post Comment</button>
</form>
<div class="comments-list">
<% comments.forEach(c => { %>
<div class="comment">
<div class="comment-header">
<strong><%= c.name %></strong>
<small><%= new Date(c.created_at).toLocaleDateString('id-ID') %></small>
</div>
<p><%= c.content %></p>
<% if (c.replies && c.replies.length > 0) { %>
<div class="replies">
<% c.replies.forEach(r => { %>
<div class="comment reply">
<div class="comment-header">
<strong><%= r.name %></strong>
<small><%= new Date(r.created_at).toLocaleDateString('id-ID') %></small>
</div>
<p><%= r.content %></p>
</div>
<% }) %>
</div>
<% } %>
</div>
<% }) %>
</div>
</section>
</div>
<script>
document.querySelectorAll('pre').forEach(pre => {
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = '๐ Copy';
btn.onclick = () => {
const code = pre.querySelector('code').innerText;
navigator.clipboard.writeText(code);
btn.textContent = 'โ
Copied!';
setTimeout(() => btn.textContent = '๐ Copy', 2000);
};
pre.style.position = 'relative';
pre.appendChild(btn);
});
</script>
</body>
</html>๐ท views/public/tag.ejs
<!DOCTYPE html>
<html lang="id" data-theme="<%= theme %>">
<head>
<meta charset="UTF-8">
<title>Tag: <%= tag.name %></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<a href="/" class="back">โ Back</a>
<h1>๐ท #<%= tag.name %></h1>
<p class="subtitle"><%= posts.length %> posts</p>
<div class="posts-grid" style="margin-top:20px;">
<% posts.forEach(post => { %>
<article class="post-card">
<a href="/post/<%= post.slug %>" class="card-link">
<div class="thumbnail" style="background-image: url('<%= post.thumbnail %>')"></div>
<div class="card-body">
<span class="category-badge"><%= post.category %></span>
<h2><%= post.title %></h2>
<p class="excerpt"><%= post.excerpt %>...</p>
<div class="meta">
<small>๐
<%= post.formattedDate %></small>
<small>โฑ <%= post.readTime %> min</small>
</div>
</div>
</a>
</article>
<% }) %>
</div>
</div>
</body>
</html>๐จ public/css/style.css
:root {
--bg: #f4f4f4; --card: #fff; --text: #333; --muted: #888;
--border: #eee; --primary: #4F46E5; --accent: #EEF2FF;
}
[data-theme="dark"] {
--bg: #0f172a; --card: #1e293b; --text: #e2e8f0; --muted: #94a3b8;
--border: #334155; --primary: #818cf8; --accent: #312e81;
}
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
body { background:var(--bg); padding:20px; color:var(--text); transition:all 0.3s; }
.container { max-width:1100px; margin:0 auto; background:var(--card); padding:30px; border-radius:10px; box-shadow:0 2px 10px rgba(0,0,0,0.05); }
.main-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:25px; border-bottom:2px solid var(--border); padding-bottom:20px; }
.main-header h1 { font-size:28px; }
.subtitle { color:var(--muted); font-size:14px; margin-top:4px; }
.header-actions { display:flex; gap:10px; align-items:center; }
.theme-btn { background:none; border:2px solid var(--border); padding:8px 14px; border-radius:6px; cursor:pointer; font-size:18px; }
.btn { background:var(--primary); color:white; padding:10px 20px; text-decoration:none; border-radius:6px; border:none; cursor:pointer; display:inline-block; font-size:14px; font-weight:500; }
.btn:hover { opacity:0.9; }
.search-form { display:flex; gap:10px; margin-bottom:20px; }
.search-input { flex:1; padding:12px 16px; border:2px solid var(--border); border-radius:8px; font-size:15px; background:var(--card); color:var(--text); }
.search-input:focus { border-color:var(--primary); outline:none; }
.categories { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:25px; }
.cat-pill { padding:6px 14px; background:var(--border); color:var(--text); border-radius:20px; text-decoration:none; font-size:13px; }
.cat-pill.active { background:var(--primary); color:white; }
.posts-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:20px; }
.post-card { background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; transition:all 0.2s; }
.post-card:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(0,0,0,0.1); }
.post-card.featured { border:2px solid var(--primary); }
.card-link { text-decoration:none; color:inherit; display:block; }
.thumbnail { width:100%; height:160px; background-size:cover; background-position:center; background-color:var(--border); }
.card-body { padding:16px; }
.card-body h2, .card-body h3 { font-size:18px; margin:8px 0; color:var(--text); line-height:1.3; }
.excerpt { color:var(--muted); font-size:14px; line-height:1.5; margin:8px 0; }
.category-badge { display:inline-block; background:var(--accent); color:var(--primary); padding:3px 10px; border-radius:12px; font-size:11px; font-weight:600; text-transform:uppercase; }
.meta { display:flex; gap:10px; margin-top:10px; flex-wrap:wrap; }
.meta small { color:var(--muted); font-size:12px; }
.tags { display:flex; gap:6px; flex-wrap:wrap; margin-top:10px; }
.tags-top { margin:10px 0 20px; }
.tag { background:var(--border); color:var(--text); padding:3px 10px; border-radius:12px; font-size:12px; text-decoration:none; }
.tag:hover { background:var(--primary); color:white; }
.post-detail { max-width:800px; margin:0 auto; }
.post-thumbnail { width:100%; height:300px; background-size:cover; background-position:center; border-radius:10px; margin-bottom:25px; }
.post-detail h1 { font-size:32px; line-height:1.3; margin:10px 0; }
.post-meta { display:flex; gap:15px; margin:10px 0 20px; }
.post-meta small { color:var(--muted); }
.video-wrap { position:relative; padding-top:56.25%; border-radius:10px; overflow:hidden; margin-bottom:25px; }
.video-wrap iframe { position:absolute; top:0; left:0; width:100%; height:100%; }
.post-video { width:100%; border-radius:10px; margin-bottom:25px; background:#000; }
.share-buttons { display:flex; gap:8px; flex-wrap:wrap; margin:20px 0; padding:15px; background:var(--accent); border-radius:8px; }
.share-btn { padding:8px 16px; border-radius:6px; text-decoration:none; font-size:13px; border:none; cursor:pointer; color:white; }
.share-btn.twitter { background:#000; }
.share-btn.telegram { background:#0088cc; }
.share-btn.reddit { background:#ff4500; }
.share-btn.copy { background:#6b7280; }
.back { color:var(--primary); text-decoration:none; display:inline-block; margin-bottom:20px; }
.empty { text-align:center; color:var(--muted); padding:60px 20px; grid-column:1/-1; }
.tags-cloud { margin-top:40px; padding-top:20px; border-top:1px solid var(--border); }
.tags-cloud h3 { margin-bottom:10px; }
.tags-cloud .tag { display:inline-block; margin:3px; }
.footer { text-align:center; margin-top:40px; padding-top:20px; border-top:1px solid var(--border); color:var(--muted); font-size:14px; }
.footer a { color:var(--primary); }
.related { margin-top:50px; padding-top:30px; border-top:2px solid var(--border); }
.featured-section { margin-bottom:30px; }
.featured-section h2 { margin-bottom:15px; }
.comments-section { margin-top:50px; padding-top:30px; border-top:2px solid var(--border); }
.comment-form { display:flex; flex-direction:column; gap:10px; margin:20px 0; }
.comment-form input, .comment-form textarea { padding:10px; border:1px solid var(--border); border-radius:6px; background:var(--card); color:var(--text); }
.comment { background:var(--bg); padding:15px; border-radius:8px; margin-bottom:10px; }
.comment-header { display:flex; justify-content:space-between; margin-bottom:8px; }
.comment-header small { color:var(--muted); }
.reply { margin-left:30px; margin-top:10px; background:var(--card); }
.markdown-body { margin:30px 0; line-height:1.7; font-size:16px; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin:24px 0 16px; }
.markdown-body h1 { font-size:2em; border-bottom:1px solid var(--border); padding-bottom:0.3em; }
.markdown-body h2 { font-size:1.5em; border-bottom:1px solid var(--border); padding-bottom:0.3em; }
.markdown-body p { margin:16px 0; }
.markdown-body ul, .markdown-body ol { margin:16px 0; padding-left:2em; }
.markdown-body a { color:var(--primary); }
.markdown-body img { max-width:100%; border-radius:5px; }
.markdown-body code { background:var(--border); padding:2px 6px; border-radius:3px; font-family:Monaco,monospace; font-size:0.9em; color:#e83e8c; }
.markdown-body pre { background:#0d1117; padding:16px; border-radius:8px; overflow-x:auto; margin:16px 0; position:relative; }
.markdown-body pre code { background:transparent; color:#c9d1d9; padding:0; }
.markdown-body blockquote { border-left:4px solid var(--border); padding:0 1em; color:var(--muted); margin:16px 0; }
.copy-btn { position:absolute; top:8px; right:8px; background:#30363d; color:#c9d1d9; border:1px solid #484f58; padding:4px 10px; border-radius:4px; cursor:pointer; font-size:12px; }
.badge { display:inline-block; padding:2px 8px; background:var(--border); border-radius:4px; font-size:11px; }
.status-private { background:#fef3c7; color:#92400e; }๐จ public/css/admin.css
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,sans-serif; }
body { background:#f5f5f7; color:#333; }
.admin-nav { background:#1e293b; color:white; padding:15px 30px; display:flex; justify-content:space-between; align-items:center; }
.nav-brand a { color:white; text-decoration:none; font-size:18px; font-weight:600; }
.nav-links { display:flex; gap:20px; align-items:center; }
.nav-links a { color:#cbd5e1; text-decoration:none; font-size:14px; }
.nav-links a:hover { color:white; }
.nav-links .logout { color:#f87171; }
.admin-container { max-width:1200px; margin:30px auto; padding:0 30px; }
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
.btn { background:#4F46E5; color:white; padding:10px 20px; text-decoration:none; border-radius:6px; border:none; cursor:pointer; display:inline-block; font-size:14px; }
.btn:hover { background:#4338CA; }
.btn-block { width:100%; }
.btn-sm { padding:6px 12px; font-size:12px; background:#6b7280; color:white; border:none; border-radius:4px; text-decoration:none; cursor:pointer; display:inline-block; }
.btn-danger { background:#dc2626; }
.icon-btn { background:none; border:none; cursor:pointer; font-size:18px; }
.stats-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:15px; margin-bottom:30px; }
.stat-card { background:white; padding:20px; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
.stat-label { font-size:13px; color:#888; margin-bottom:8px; }
.stat-value { font-size:32px; font-weight:700; color:#4F46E5; }
.dashboard-grid { display:grid; grid-template-columns:2fr 1fr 1fr; gap:20px; }
@media (max-width:900px) { .dashboard-grid { grid-template-columns:1fr; } }
.card { background:white; padding:20px; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,0.05); margin-bottom:15px; }
.card h2, .card h3 { margin-bottom:15px; }
.simple-list { list-style:none; }
.simple-list li { padding:10px 0; border-bottom:1px solid #eee; display:flex; justify-content:space-between; align-items:center; }
.simple-list a { color:#333; text-decoration:none; flex:1; }
.simple-list a:hover { color:#4F46E5; }
.filter-tabs { display:flex; gap:5px; margin-bottom:20px; border-bottom:2px solid #eee; }
.tab { padding:10px 20px; text-decoration:none; color:#666; border-bottom:3px solid transparent; margin-bottom:-2px; }
.tab.active { color:#4F46E5; border-bottom-color:#4F46E5; }
.admin-table { width:100%; background:white; border-radius:10px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.05); border-collapse:collapse; }
.admin-table th, .admin-table td { padding:12px 16px; text-align:left; border-bottom:1px solid #eee; }
.admin-table th { background:#f9fafb; font-size:13px; text-transform:uppercase; color:#666; }
.admin-table tbody tr:hover { background:#f9fafb; }
.badge { display:inline-block; padding:3px 10px; border-radius:12px; font-size:11px; background:#e5e7eb; }
.status-published { background:#d1fae5; color:#065f46; }
.status-draft { background:#fef3c7; color:#92400e; }
.status-private { background:#ddd6fe; color:#5b21b6; }
.editor-form label { display:block; margin-top:12px; margin-bottom:6px; font-weight:600; font-size:13px; color:#444; }
.editor-form input, .editor-form textarea, .editor-form select { width:100%; padding:10px; border:2px solid #e5e7eb; border-radius:6px; font-size:14px; font-family:inherit; }
.editor-form textarea { font-family:Monaco,monospace; }
.editor-form input:focus, .editor-form textarea:focus { border-color:#4F46E5; outline:none; }
.editor-grid { display:grid; grid-template-columns:2fr 1fr; gap:20px; margin-top:20px; }
@media (max-width:900px) { .editor-grid { grid-template-columns:1fr; } }
.editor-toolbar { display:flex; gap:8px; margin:8px 0; }
.editor-toolbar button { padding:6px 12px; background:#e5e7eb; border:none; border-radius:4px; cursor:pointer; font-size:12px; }