feat: add admin CLI, SEO meta tags, security headers, and production Makefile targets

This commit is contained in:
2026-05-11 03:30:51 +00:00
parent fda3a202dc
commit 140cceff64
49 changed files with 2116 additions and 188 deletions
+34 -43
View File
@@ -8,8 +8,10 @@ class Application {
this.initMessageSearch();
this.initNotificationDismiss();
this.initProfileEdit();
this.initProjectForm();
this.initFormDisable();
this.initContentRenderer();
this.initEmojiPickers();
}
loadCSS(href) {
@@ -38,7 +40,8 @@ class Application {
initModals() {
document.querySelectorAll("[data-modal]").forEach((trigger) => {
trigger.addEventListener("click", () => {
trigger.addEventListener("click", (e) => {
e.preventDefault();
const modalId = trigger.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
@@ -53,12 +56,11 @@ class Application {
modal.classList.remove("visible");
}
});
const closeBtn = modal.querySelector(".modal-close");
if (closeBtn) {
modal.querySelectorAll(".modal-close").forEach((closeBtn) => {
closeBtn.addEventListener("click", () => {
modal.classList.remove("visible");
});
}
});
});
}
@@ -186,49 +188,38 @@ class Application {
});
}
initProjectForm() {
const platformsInput = document.getElementById("platforms-input");
if (!platformsInput) {
return;
}
const hiddenInput = document.getElementById("platforms");
const tagsContainer = document.getElementById("platforms-tags");
initContentRenderer() {
document.querySelectorAll("[data-render]").forEach((el) => {
contentRenderer.applyTo(el);
});
contentRenderer.highlightAll();
}
platformsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = platformsInput.value.trim();
if (!val) {
return;
initEmojiPickers() {
document.querySelectorAll(".comment-form textarea, .emoji-picker-target").forEach((textarea) => {
if (textarea.dataset.emojiInitialized) return;
textarea.dataset.emojiInitialized = "true";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "emoji-toggle-btn";
btn.title = "Add emoji";
btn.innerHTML = "\u{1F600}";
const picker = new EmojiPicker(textarea);
btn.addEventListener("click", () => picker.toggle());
const parent = textarea.parentElement;
if (parent) {
const actions = parent.querySelector(".comment-form-actions");
if (actions) {
actions.insertBefore(btn, actions.firstChild);
}
const tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
remove.addEventListener("click", () => {
tag.remove();
updatePlatforms();
});
tag.appendChild(remove);
tagsContainer.appendChild(tag);
platformsInput.value = "";
updatePlatforms();
}
});
function updatePlatforms() {
const tags = tagsContainer.querySelectorAll(".platform-tag");
const values = Array.from(tags).map((t) =>
t.textContent.replace("x", "").trim()
);
hiddenInput.value = values.join(",");
}
}
}
const app = new Application();
+168
View File
@@ -0,0 +1,168 @@
class ContentRenderer {
constructor() {
this.emojiMap = this.buildEmojiMap();
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
this.youtubeRe = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
}
buildEmojiMap() {
return {
"grinning": "\u{1F600}", "smiley": "\u{1F603}", "smile": "\u{1F604}",
"grin": "\u{1F601}", "laughing": "\u{1F606}", "sweat_smile": "\u{1F605}",
"joy": "\u{1F602}", "blush": "\u{1F60A}", "innocent": "\u{1F607}",
"wink": "\u{1F609}", "heart_eyes": "\u{1F60D}", "kissing_heart": "\u{1F618}",
"stuck_out_tongue": "\u{1F61B}", "stuck_out_tongue_winking_eye": "\u{1F61C}",
"sunglasses": "\u{1F60E}", "unamused": "\u{1F612}", "sweat": "\u{1F613}",
"sob": "\u{1F62D}", "scream": "\u{1F631}", "sleeping": "\u{1F634}",
"relieved": "\u{1F60C}", "heart": "\u{2764}\u{FE0F}", "broken_heart": "\u{1F494}",
"two_hearts": "\u{1F495}", "sparkling_heart": "\u{1F496}",
"star": "\u{2B50}", "sparkles": "\u{2728}", "zap": "\u{26A1}",
"fire": "\u{1F525}", "rocket": "\u{1F680}", "100": "\u{1F4AF}",
"clap": "\u{1F44F}", "ok_hand": "\u{1F44C}", "wave": "\u{1F44B}",
"thumbsup": "\u{1F44D}", "+1": "\u{1F44D}", "thumbsdown": "\u{1F44E}",
"-1": "\u{1F44E}", "pray": "\u{1F64F}", "muscle": "\u{1F4AA}",
"tada": "\u{1F389}", "confetti_ball": "\u{1F38A}", "party": "\u{1F973}",
"beers": "\u{1F37B}", "coffee": "\u{2615}", "pizza": "\u{1F355}",
"computer": "\u{1F4BB}", "bug": "\u{1F41B}", "gear": "\u{2699}\u{FE0F}",
"warning": "\u{26A0}\u{FE0F}", "question": "\u{2753}", "exclamation": "\u{2757}",
"checkered_flag": "\u{1F3C1}", "rocket": "\u{1F680}",
"eyes": "\u{1F440}", "brain": "\u{1F9E0}", "robot": "\u{1F916}",
"skull": "\u{1F480}", "point_up": "\u{261D}\u{FE0F}", "point_down": "\u{1F447}",
"point_left": "\u{1F448}", "point_right": "\u{1F449}",
"pencil2": "\u{270F}\u{FE0F}", "memo": "\u{1F4DD}", "book": "\u{1F4D6}",
"tools": "\u{1F6E0}\u{FE0F}", "hammer_and_wrench": "\u{1F6E0}\u{FE0F}",
};
}
replaceShortcodes(text) {
return text.replace(/:([a-zA-Z0-9_+\-]+):/g, (match, name) => {
return this.emojiMap[name.toLowerCase()] || match;
});
}
render(text) {
if (!text) return "";
text = this.replaceShortcodes(text);
let html;
if (typeof marked !== "undefined") {
html = marked.parse(text, { breaks: true, gfm: true });
} else {
html = "<p>" + text.replace(/\n/g, "<br>") + "</p>";
}
html = this.processMedia(html);
return html;
}
processMedia(html) {
const temp = document.createElement("div");
temp.innerHTML = html;
this.walkNodes(temp, (node) => {
if (node.nodeType !== 3) return;
const text = node.textContent;
if (!text || text.trim().length === 0) return;
const parts = [];
let lastIndex = 0;
const urlRe = /(https?:\/\/[^\s<]+)/g;
let match;
while ((match = urlRe.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: "text", value: text.substring(lastIndex, match.index) });
}
parts.push({ type: "url", value: match[0], start: match.index, end: match.index + match[0].length });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.substring(lastIndex) });
}
if (parts.length <= 1 && parts[0]?.type === "text") return;
const fragment = document.createDocumentFragment();
for (const part of parts) {
if (part.type === "text") {
fragment.appendChild(document.createTextNode(part.value));
} else {
const el = this.urlToEmbed(part.value);
if (el) {
fragment.appendChild(el);
} else {
const a = document.createElement("a");
a.href = part.value;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = part.value;
fragment.appendChild(a);
}
}
}
node.parentNode.replaceChild(fragment, node);
});
return temp.innerHTML;
}
urlToEmbed(url) {
const ytMatch = url.match(this.youtubeRe);
if (ytMatch) {
const videoId = ytMatch[1];
const wrap = document.createElement("div");
wrap.className = "embed-youtube";
wrap.innerHTML = (
'<iframe src="https://www.youtube.com/embed/' + videoId + '" ' +
'frameborder="0" allowfullscreen ' +
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture">' +
'</iframe>'
);
return wrap;
}
if (this.imageExtRe.test(url)) {
const img = document.createElement("img");
img.src = url;
img.alt = "";
img.loading = "lazy";
return img;
}
return null;
}
walkNodes(root, callback) {
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "SCRIPT", "STYLE"]);
const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = iter.nextNode())) {
let parent = node.parentNode;
let skip = false;
while (parent) {
if (skipTags.has(parent.tagName)) { skip = true; break; }
parent = parent.parentNode;
}
if (!skip) callback(node);
}
}
applyTo(element) {
if (!element) return;
const text = element.textContent || element.innerText || "";
const rendered = this.render(text);
element.innerHTML = rendered;
}
highlightAll() {
if (typeof hljs !== "undefined") {
document.querySelectorAll("pre code").forEach((el) => {
hljs.highlightElement(el);
});
}
}
}
const contentRenderer = new ContentRenderer();
+60
View File
@@ -0,0 +1,60 @@
class EmojiPicker {
constructor(textarea) {
this.textarea = textarea;
this.picker = null;
this.wrapper = null;
this.build();
}
build() {
this.wrapper = document.createElement("div");
this.wrapper.className = "emoji-picker-wrapper";
this.wrapper.style.display = "none";
this.wrapper.style.position = "absolute";
this.wrapper.style.bottom = "100%";
this.wrapper.style.right = "0";
this.wrapper.style.zIndex = "2000";
const picker = document.createElement("emoji-picker");
picker.style.width = "320px";
picker.style.height = "350px";
this.wrapper.appendChild(picker);
picker.addEventListener("emoji-click", (event) => {
this.insert(event.detail.unicode);
this.hide();
});
this.picker = picker;
const parent = this.textarea.parentElement;
if (parent) {
parent.style.position = "relative";
parent.appendChild(this.wrapper);
}
}
insert(unicode) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = ta.value;
ta.value = text.substring(0, start) + unicode + text.substring(end);
const newPos = start + unicode.length;
ta.setSelectionRange(newPos, newPos);
ta.focus();
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
toggle() {
if (this.wrapper.style.display === "none") {
this.wrapper.style.display = "block";
} else {
this.wrapper.style.display = "none";
}
}
hide() {
this.wrapper.style.display = "none";
}
}