Add lightweight self-hosted Hugo CMS with GitHub API backend
Implements a Go-based CMS for editing Hugo posts and uploading images via the GitHub REST API, with no database and Git as the source of truth. Features: - Single admin auth with bcrypt password and signed session cookies - Dashboard, post listing with search, create/edit with EasyMDE editor - Media library for static/uploads/ with drag-drop and clipboard paste - Client-side autosave, unsaved changes warning, dark mode, responsive UI - Modular packages for auth, github, posts, media, session, and handlers Co-authored-by: codegirl007 <s.raide@gmail.com>
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
:root {
|
||||
--bg: #f8f9fb;
|
||||
--surface: #ffffff;
|
||||
--text: #1a1d26;
|
||||
--text-muted: #5c6370;
|
||||
--border: #e2e5eb;
|
||||
--primary: #3b6ef5;
|
||||
--primary-hover: #2f5ad4;
|
||||
--danger: #d64545;
|
||||
--success: #2d9f6f;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--radius: 8px;
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #12151c;
|
||||
--surface: #1c2130;
|
||||
--text: #e8eaef;
|
||||
--text-muted: #9aa3b2;
|
||||
--border: #2d3548;
|
||||
--primary: #6b93ff;
|
||||
--primary-hover: #89a8ff;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-form { margin: 0; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn:hover { text-decoration: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover { color: var(--text); }
|
||||
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.6rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
|
||||
|
||||
/* Cards & layout */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--bg);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-draft {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-published {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge-draft {
|
||||
background: #3d3419;
|
||||
color: #ffd666;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge-published {
|
||||
background: #1a3d2b;
|
||||
color: #6fcf97;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form label,
|
||||
.post-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form label span,
|
||||
.post-form label span {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="search"],
|
||||
input[type="datetime-local"],
|
||||
textarea {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0 1rem;
|
||||
}
|
||||
|
||||
.span-2 { grid-column: span 2; }
|
||||
|
||||
.checkbox-label {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.checkbox-label input { width: auto; }
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-bar input { flex: 1; min-width: 200px; }
|
||||
|
||||
/* Login */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fde8e8;
|
||||
color: var(--danger);
|
||||
border: 1px solid #f5c6c6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .alert-error {
|
||||
background: #3d1f1f;
|
||||
border-color: #5c2a2a;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.save-status.saving { color: var(--primary); }
|
||||
.save-status.saved { color: var(--success); }
|
||||
.save-status.error { color: var(--danger); }
|
||||
|
||||
/* Editor */
|
||||
.editor-label { margin-top: 1rem; }
|
||||
|
||||
.editor-toolbar-extra {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.EasyMDEContainer {
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .EasyMDEContainer .CodeMirror,
|
||||
[data-theme="dark"] .editor-toolbar {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-preview {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Media */
|
||||
.upload-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.media-grid-page {
|
||||
max-height: none;
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.media-meta code {
|
||||
font-size: 0.7rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal.hidden { display: none; }
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
background: var(--bg);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.link { font-size: 0.9rem; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.span-2 { grid-column: span 1; }
|
||||
|
||||
.topnav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const THEME_KEY = 'cms-theme';
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = saved || (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', function () {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initTheme);
|
||||
})();
|
||||
@@ -0,0 +1,289 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const AUTOSAVE_INTERVAL = 30000;
|
||||
const DRAFT_KEY_PREFIX = 'cms-draft-';
|
||||
|
||||
let editor;
|
||||
let dirty = false;
|
||||
let autosaveTimer;
|
||||
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function getFormData() {
|
||||
return {
|
||||
title: document.getElementById('title').value,
|
||||
slug: document.getElementById('slug').value,
|
||||
date: document.getElementById('date').value,
|
||||
draft: document.getElementById('draft').checked,
|
||||
tags: document.getElementById('tags').value,
|
||||
body: editor ? editor.value() : document.getElementById('body').value,
|
||||
original: document.getElementById('original').value,
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(text, className) {
|
||||
const el = document.getElementById('save-status');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = 'save-status ' + (className || '');
|
||||
}
|
||||
|
||||
function draftKey() {
|
||||
const original = document.getElementById('original').value;
|
||||
const slug = document.getElementById('slug').value;
|
||||
return DRAFT_KEY_PREFIX + (original || slug || 'new');
|
||||
}
|
||||
|
||||
function saveDraftLocal() {
|
||||
try {
|
||||
localStorage.setItem(draftKey(), JSON.stringify(getFormData()));
|
||||
} catch (_) { /* quota exceeded */ }
|
||||
}
|
||||
|
||||
function loadDraftLocal() {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey());
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw);
|
||||
if (!confirm('A local autosave draft was found. Restore it?')) {
|
||||
localStorage.removeItem(draftKey());
|
||||
return;
|
||||
}
|
||||
document.getElementById('title').value = data.title || '';
|
||||
document.getElementById('slug').value = data.slug || '';
|
||||
document.getElementById('date').value = data.date || '';
|
||||
document.getElementById('draft').checked = !!data.draft;
|
||||
document.getElementById('tags').value = data.tags || '';
|
||||
if (editor) editor.value(data.body || '');
|
||||
dirty = true;
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function clearDraftLocal() {
|
||||
localStorage.removeItem(draftKey());
|
||||
}
|
||||
|
||||
async function savePost() {
|
||||
const data = getFormData();
|
||||
setStatus('Saving…', 'saving');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/posts/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || 'Save failed');
|
||||
|
||||
dirty = false;
|
||||
clearDraftLocal();
|
||||
setStatus('Saved', 'saved');
|
||||
|
||||
if (result.slug && result.slug !== data.original) {
|
||||
document.getElementById('original').value = result.slug;
|
||||
history.replaceState(null, '', '/admin/posts/' + result.slug);
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
if (!dirty) setStatus('', '');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
const res = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || 'Upload failed');
|
||||
return result;
|
||||
}
|
||||
|
||||
function insertMarkdown(text) {
|
||||
if (!editor) return;
|
||||
const cm = editor.codemirror;
|
||||
const doc = cm.getDoc();
|
||||
const cursor = doc.getCursor();
|
||||
doc.replaceRange(text, cursor);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
async function handleImageUpload(file) {
|
||||
try {
|
||||
setStatus('Uploading image…', 'saving');
|
||||
const result = await uploadImage(file);
|
||||
insertMarkdown('');
|
||||
setStatus('Image uploaded', 'saved');
|
||||
setTimeout(function () { if (!dirty) setStatus('', ''); }, 2000);
|
||||
} catch (err) {
|
||||
setStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openMediaModal() {
|
||||
const modal = document.getElementById('media-modal');
|
||||
const grid = document.getElementById('media-grid');
|
||||
modal.classList.remove('hidden');
|
||||
grid.innerHTML = '<p>Loading…</p>';
|
||||
|
||||
fetch('/api/media')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
grid.innerHTML = '';
|
||||
if (!data.items || !data.items.length) {
|
||||
grid.innerHTML = '<p class="empty-state">No images yet.</p>';
|
||||
return;
|
||||
}
|
||||
data.items.forEach(function (item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'media-item';
|
||||
div.innerHTML = '<img src="' + item.url + '" alt="' + item.name + '">';
|
||||
div.addEventListener('click', function () {
|
||||
insertMarkdown('');
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
grid.appendChild(div);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
grid.innerHTML = '<p class="empty-state">Failed to load media.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
const textarea = document.getElementById('body');
|
||||
if (!textarea || typeof EasyMDE === 'undefined') return;
|
||||
|
||||
editor = new EasyMDE({
|
||||
element: textarea,
|
||||
autofocus: true,
|
||||
spellChecker: false,
|
||||
autosave: { enabled: false },
|
||||
toolbar: [
|
||||
'bold', 'italic', 'heading', '|',
|
||||
'quote', 'unordered-list', 'ordered-list', '|',
|
||||
'link', 'image', '|',
|
||||
'preview', 'side-by-side', 'fullscreen', '|',
|
||||
'guide',
|
||||
],
|
||||
status: ['lines', 'words'],
|
||||
renderingConfig: { singleLineBreaks: false },
|
||||
uploadImage: true,
|
||||
imageUploadFunction: function (file, onSuccess, onError) {
|
||||
uploadImage(file)
|
||||
.then(function (r) { onSuccess(r.url); })
|
||||
.catch(function (e) { onError(e.message); });
|
||||
},
|
||||
});
|
||||
|
||||
editor.codemirror.on('change', function () {
|
||||
dirty = true;
|
||||
});
|
||||
|
||||
// Clipboard image paste
|
||||
editor.codemirror.getWrapperElement().addEventListener('paste', function (e) {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
handleImageUpload(items[i].getAsFile());
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initSlugGeneration() {
|
||||
const title = document.getElementById('title');
|
||||
const slug = document.getElementById('slug');
|
||||
const original = document.getElementById('original').value;
|
||||
let slugManual = !!original;
|
||||
|
||||
slug.addEventListener('input', function () {
|
||||
slugManual = true;
|
||||
});
|
||||
|
||||
title.addEventListener('input', function () {
|
||||
if (!slugManual) {
|
||||
slug.value = slugify(title.value);
|
||||
}
|
||||
dirty = true;
|
||||
});
|
||||
|
||||
['slug', 'date', 'tags', 'draft'].forEach(function (id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', function () { dirty = true; });
|
||||
if (el) el.addEventListener('input', function () { dirty = true; });
|
||||
});
|
||||
}
|
||||
|
||||
function initButtons() {
|
||||
document.getElementById('save-btn').addEventListener('click', savePost);
|
||||
|
||||
document.getElementById('insert-media-btn').addEventListener('click', openMediaModal);
|
||||
|
||||
document.getElementById('upload-image-btn').addEventListener('click', function () {
|
||||
document.getElementById('image-upload').click();
|
||||
});
|
||||
|
||||
document.getElementById('image-upload').addEventListener('change', function (e) {
|
||||
if (e.target.files[0]) handleImageUpload(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-close-modal]').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
document.getElementById('media-modal').classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard shortcut: Ctrl/Cmd+S
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
savePost();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAutosave() {
|
||||
autosaveTimer = setInterval(function () {
|
||||
if (dirty) saveDraftLocal();
|
||||
}, AUTOSAVE_INTERVAL);
|
||||
}
|
||||
|
||||
function initUnsavedWarning() {
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (dirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initEditor();
|
||||
initSlugGeneration();
|
||||
initButtons();
|
||||
initAutosave();
|
||||
initUnsavedWarning();
|
||||
loadDraftLocal();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,48 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const form = document.getElementById('media-upload-form');
|
||||
const status = document.getElementById('upload-status');
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
const fileInput = document.getElementById('media-file');
|
||||
if (!fileInput.files[0]) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
status.textContent = 'Uploading…';
|
||||
status.className = 'save-status saving';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || 'Upload failed');
|
||||
|
||||
status.textContent = 'Uploaded!';
|
||||
status.className = 'save-status saved';
|
||||
fileInput.value = '';
|
||||
setTimeout(function () { window.location.reload(); }, 800);
|
||||
} catch (err) {
|
||||
status.textContent = err.message;
|
||||
status.className = 'save-status error';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.copy-md').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const url = btn.getAttribute('data-url');
|
||||
const md = '';
|
||||
navigator.clipboard.writeText(md).then(function () {
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function () { btn.textContent = 'Copy Markdown'; }, 1500);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user