Files
blogging-cms/static/js/app.js
T
nutshell9247 852613586f
CI/CD Pipeline / Test & Lint (push) Waiting to run
CI/CD Pipeline / Build Docker Image (push) Blocked by required conditions
CI/CD Pipeline / Security Scan (push) Waiting to run
first commit, mate!
2026-08-08 14:43:59 +08:00

473 lines
13 KiB
JavaScript

// API Base
const API_BASE = '/api';
// Utility functions
const api = {
async get(endpoint) {
const response = await fetch(`${API_BASE}${endpoint}`);
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
async post(endpoint, data) {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
async put(endpoint, data) {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
async delete(endpoint) {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
};
// Toast notifications
class Toast {
static show(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
animation: slideIn 0.3s ease;
border-left: 4px solid var(--md-sys-color-primary);
`;
if (type === 'success') {
toast.style.borderLeftColor = '#4caf50';
} else if (type === 'error') {
toast.style.borderLeftColor = 'var(--md-sys-color-error)';
}
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
}
}
// Modal dialog
class Modal {
constructor(title, content) {
this.title = title;
this.content = content;
this.element = null;
}
open() {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
animation: fadeIn 0.2s ease;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background-color: var(--md-sys-color-surface);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
animation: slideUp 0.3s ease;
`;
const titleEl = document.createElement('h2');
titleEl.textContent = this.title;
titleEl.style.cssText = 'margin: 0 0 16px 0;';
const contentEl = document.createElement('div');
contentEl.innerHTML = this.content;
contentEl.style.cssText = 'margin-bottom: 24px;';
dialog.appendChild(titleEl);
dialog.appendChild(contentEl);
modal.appendChild(dialog);
modal.addEventListener('click', (e) => {
if (e.target === modal) this.close();
});
this.element = modal;
document.body.appendChild(modal);
}
close() {
if (this.element) {
this.element.style.animation = 'fadeOut 0.2s ease';
setTimeout(() => this.element.remove(), 200);
}
}
}
// Post editor
class PostEditor {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.postId = null;
this.init();
}
init() {
this.container.innerHTML = `
<form id="post-form" class="card">
<h2>Create New Post</h2>
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" required placeholder="Post title">
</div>
<div class="form-group">
<label for="type">Post Type</label>
<select id="type" name="type">
<option value="post">Blog Post</option>
<option value="micro">Micro Post</option>
</select>
</div>
<div class="form-group">
<label for="category">Category</label>
<input type="text" id="category" name="category" placeholder="e.g., Technology, Personal">
</div>
<div class="form-group">
<label for="excerpt">Excerpt</label>
<textarea id="excerpt" name="excerpt" placeholder="Brief summary of the post"></textarea>
</div>
<div class="form-group">
<label for="content">Content (Markdown)</label>
<textarea id="content" name="content" required placeholder="Write your content in Markdown..."></textarea>
<div style="font-size: 12px; color: var(--md-sys-color-on-surface-variant); margin-top: 8px;">
Supports Markdown syntax. Preview available.
</div>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" placeholder="tag1, tag2, tag3">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="published" name="published">
Publish immediately
</label>
</div>
<div style="display: flex; gap: 16px;">
<button type="submit" class="button button--primary">Save Post</button>
<button type="button" id="preview-btn" class="button button--secondary">Preview</button>
</div>
</form>
`;
this.form = document.getElementById('post-form');
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
document.getElementById('preview-btn').addEventListener('click', () => this.showPreview());
}
async handleSubmit(e) {
e.preventDefault();
const formData = {
title: document.getElementById('title').value,
content: document.getElementById('content').value,
excerpt: document.getElementById('excerpt').value,
type: document.getElementById('type').value,
category: document.getElementById('category').value,
tags: document.getElementById('tags').value.split(',').map(t => t.trim()).filter(t => t),
published: document.getElementById('published').checked,
};
try {
const endpoint = this.postId ? `/posts/${this.postId}` : '/posts';
const method = this.postId ? 'put' : 'post';
const result = await api[method](endpoint, formData);
if (result.success) {
Toast.show('Post saved successfully!', 'success');
if (!this.postId) {
this.postId = result.data.id;
}
setTimeout(() => window.location.href = '/', 1500);
}
} catch (error) {
Toast.show('Error saving post: ' + error.message, 'error');
}
}
showPreview() {
const content = document.getElementById('content').value;
const title = document.getElementById('title').value;
const previewHtml = `
<div style="padding: 24px;">
<h1>${title || 'Untitled'}</h1>
<div id="preview-content" style="margin-top: 24px;">
${this.markdownToHtml(content)}
</div>
</div>
`;
const modal = new Modal('Preview', previewHtml);
modal.open();
}
markdownToHtml(markdown) {
let html = markdown
.replace(/^### (.*?)$/gm, '<h3>$1</h3>')
.replace(/^## (.*?)$/gm, '<h2>$1</h2>')
.replace(/^# (.*?)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/gm, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gm, '<em>$1</em>')
.replace(/`(.*?)`/gm, '<code>$1</code>')
.replace(/\n\n/gm, '</p><p>')
.replace(/^/gm, '<p>')
.replace(/$/gm, '</p>');
return html;
}
}
// Posts list
class PostsList {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.page = 1;
this.loading = false;
this.init();
}
async init() {
this.container.innerHTML = '<div style="text-align: center; padding: 40px;">Loading posts...</div>';
await this.loadPosts();
}
async loadPosts() {
if (this.loading) return;
this.loading = true;
try {
const result = await api.get(`/posts?page=${this.page}`);
if (result.success && result.data) {
this.render(result.data);
}
} catch (error) {
this.container.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--md-sys-color-error);">Error loading posts</div>`;
}
this.loading = false;
}
render(posts) {
if (posts.length === 0) {
this.container.innerHTML = '<div style="text-align: center; padding: 40px;">No posts yet.</div>';
return;
}
let html = '<ul class="post-list">';
posts.forEach(post => {
const date = new Date(post.created_at).toLocaleDateString();
html += `
<li class="post-item">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<h3 class="post-title">
<a href="/post/${post.slug}">${post.title}</a>
</h3>
<span class="post-type ${post.type === 'micro' ? 'post-type--micro' : ''}">
${post.type === 'micro' ? '💬' : '📝'} ${post.type}
</span>
</div>
<div class="post-meta">
<span>${date}</span>
${post.category ? `<span>${post.category}</span>` : ''}
</div>
<p class="post-excerpt">${post.excerpt || post.content.substring(0, 200)}</p>
<a href="/post/${post.slug}" style="font-weight: 500;">Read more →</a>
</li>
`;
});
html += '</ul>';
this.container.innerHTML = html;
// Pagination
if (posts.length >= 20) {
const paginationBtn = document.createElement('div');
paginationBtn.style.cssText = 'text-align: center; margin-top: 40px;';
paginationBtn.innerHTML = `
<button class="button button--secondary" id="load-more-btn">Load More Posts</button>
`;
this.container.appendChild(paginationBtn);
document.getElementById('load-more-btn').addEventListener('click', () => {
this.page++;
this.loadPosts();
});
}
}
}
// Search functionality
class SearchBox {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.init();
}
init() {
this.input = this.container.querySelector('input[type="search"]');
if (!this.input) return;
this.input.addEventListener('keyup', (e) => this.search(e.target.value));
}
async search(query) {
if (!query || query.length < 2) {
this.clearResults();
return;
}
try {
const result = await api.get(`/search?q=${encodeURIComponent(query)}`);
this.renderResults(result.data || []);
} catch (error) {
console.error('Search error:', error);
}
}
renderResults(posts) {
let html = '<div style="position: absolute; top: 100%; left: 0; right: 0; background: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 100; max-height: 400px; overflow-y: auto;">';
posts.slice(0, 10).forEach(post => {
html += `
<a href="/post/${post.slug}" style="display: block; padding: 12px 16px; border-bottom: 1px solid #eee; color: inherit; text-decoration: none;">
<div style="font-weight: 500;">${post.title}</div>
<div style="font-size: 12px; color: #666;">${new Date(post.created_at).toLocaleDateString()}</div>
</a>
`;
});
html += '</div>';
let dropdown = document.getElementById('search-results');
if (dropdown) dropdown.remove();
const resultsDiv = document.createElement('div');
resultsDiv.id = 'search-results';
resultsDiv.innerHTML = html;
resultsDiv.style.cssText = 'position: relative;';
this.container.appendChild(resultsDiv);
}
clearResults() {
const dropdown = document.getElementById('search-results');
if (dropdown) dropdown.remove();
}
}
// Initialize on document ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize any editors or lists if they exist
if (document.getElementById('post-editor')) {
new PostEditor('post-editor');
}
if (document.getElementById('posts-list')) {
new PostsList('posts-list');
}
if (document.getElementById('search-box')) {
new SearchBox('search-box');
}
});
// Add CSS animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
`;
document.head.appendChild(style);