Article web

๐Ÿš€ BLOG PRO v3.0 – Full Ghost-like Features!

0
Please log in or register to do it.

๐Ÿ“‹ 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 (biasanya localhost)
  • DB_NAME
  • DB_USER
  • DB_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=3000

File: .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![Image](${data.url})\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![Image](${data.url})\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; }

domain_status_fast

Your email address will not be published. Required fields are marked *