feat: add admin CLI, SEO meta tags, security headers, and production Makefile targets
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user