Files
f0bm/public/s/js/admin.js

416 lines
14 KiB
JavaScript

(async () => {
// Helper to get dynamic context
const getContext = () => {
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
return {
postid: +idLink.innerText,
poster: document.querySelector("a#a_username")?.innerText,
tags: [...document.querySelectorAll("#tags > .badge")].map(t => t.innerText.slice(0, -2))
};
};
const queryapi = async (url, data, method = 'GET') => {
let req;
if (method == 'POST') {
req = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
});
}
else {
let s = [];
for (const [key, val] of Object.entries(data))
s.push(encodeURIComponent(key) + "=" + encodeURIComponent(val));
req = await fetch(url + '?' + s.join('&'));
}
return await req.json();
};
const get = async (url, data) => queryapi(url, data, 'GET');
const post = async (url, data) => queryapi(url, data, 'POST');
const renderTags = _tags => {
[...document.querySelectorAll("#tags > .badge")].forEach(tag => tag.parentElement.removeChild(tag));
_tags.reverse().forEach(tag => {
const a = document.createElement("a");
a.href = `/tag/${tag.normalized}`;
a.style = "color: inherit !important";
a.innerHTML = tag.tag;
// Admin specific: edit event
// Note: delegation handles this now if we set it up, OR we can attach here since elements are new.
// But delegation is cleaner if possible. However, editTagEvent relies on 'e.target'.
const span = document.createElement("span");
span.classList.add("badge", "mr-2");
span.setAttribute('tooltip', tag.user);
tag.badge.split(" ").forEach(b => span.classList.add(b));
const delbutton = document.createElement("a");
delbutton.innerHTML = " ×";
delbutton.href = "javascript:void(0)";
// Class for delegation
delbutton.classList.add("admin-deltag");
span.insertAdjacentElement("beforeend", a);
span.innerHTML += ' ';
span.insertAdjacentElement("beforeend", delbutton);
document.querySelector("#tags").insertAdjacentElement("afterbegin", span);
});
};
const deleteEvent = async e => {
e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const { postid } = ctx;
// Use normalized target logic if possible, or assume e.target (wrapped in listener)
// In delegation, we passed 'e'. e.target might be text node if not normalized in the handler call?
// In listener I normalized 'target', but passed 'e' to deleteEvent.
// So 'e.target' is still the raw event target.
// I should check nodeType here or use closest properly.
let target = e.target;
if (target.nodeType === 3) target = target.parentElement;
const badge = target.closest('.badge');
const tagLink = badge.querySelector('a:first-child');
const tagname = tagLink.innerText;
const modal = document.getElementById('delete-tag-modal');
const nameEl = document.getElementById('delete-tag-name');
const confirmBtn = document.getElementById('delete-tag-confirm');
const cancelBtn = document.getElementById('delete-tag-cancel');
if (modal) {
nameEl.textContent = tagname;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
modal.style.display = 'flex';
const closeModal = () => {
modal.style.display = 'none';
confirmBtn.onclick = null;
cancelBtn.onclick = null;
};
cancelBtn.onclick = closeModal;
confirmBtn.onclick = async () => {
confirmBtn.textContent = 'Deleting...';
confirmBtn.disabled = true;
try {
const res = await (await fetch("/api/v2/admin/" + postid + "/tags/" + encodeURIComponent(tagname), {
method: 'DELETE'
})).json();
if (!res.success) {
alert(res.msg || "uff");
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
} else {
renderTags(res.tags);
closeModal();
}
} catch (err) {
console.error(err);
alert("Error deleting tag");
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
};
}
};
const addtagClick = (e) => {
if (e) e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const { postid, tags } = ctx;
const insert = document.querySelector("a#a_addtag");
if (insert.previousElementSibling && insert.previousElementSibling.querySelector('input')) {
insert.previousElementSibling.querySelector('input').focus();
return;
}
const span = document.createElement("span");
span.classList.add("badge", "badge-light", "mr-2");
const input = document.createElement("input");
input.size = "10";
input.value = "";
input.setAttribute("list", "testlist");
input.setAttribute("autoComplete", "off");
span.insertAdjacentElement("afterbegin", input);
insert.insertAdjacentElement("beforebegin", span);
input.focus();
let tt = null;
let lastInput = '';
const testList = document.querySelector('#testlist');
input.addEventListener("keyup", async e => {
if (e.key === "Enter") {
const tmptag = input.value?.trim();
// We should re-check tags from DOM? Or trust captured tags?
// Captured 'tags' is safe for immediate check.
if (tags.includes(tmptag))
return alert("tag already exists");
const res = await post("/api/v2/admin/" + postid + "/tags", {
tagname: tmptag
});
if (!res.success) {
alert(res.msg);
return false;
}
renderTags(res.tags);
if (span.parentElement) span.parentElement.removeChild(span);
testList.innerText = "";
addtagClick();
}
else if (e.key === "Escape") {
if (span.parentElement) span.parentElement.removeChild(span);
testList.innerText = "";
}
else {
if (tt != null) clearTimeout(tt);
tt = setTimeout(async () => {
tt = null;
const tmptag = input.value?.trim();
if (tmptag == lastInput || tmptag.length <= 1) return false;
testList.innerText = "";
lastInput = tmptag;
const res = await get('/api/v2/admin/tags/suggest', { q: tmptag });
for (const entry of res.suggestions) {
const option = document.createElement('option');
option.value = entry.tag;
if (!/fox/.test(navigator.userAgent))
option.innerText = `tagged ${entry.tagged} times (score: ${entry.score.toFixed(2)})`;
testList.insertAdjacentElement('beforeEnd', option);
};
}, 500);
}
return true;
});
input.addEventListener("focusout", ie => {
if (input.value.length === 0)
input.parentElement.parentElement.removeChild(input.parentElement);
});
};
const toggleEvent = async (e) => {
if (e) e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const { postid } = ctx;
const res = await (await fetch('/api/v2/admin/' + encodeURIComponent(postid) + '/tags/toggle', {
method: 'PUT'
})).json();
renderTags(res.tags);
};
const deleteButtonEvent = async e => {
if (e) e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const { postid, poster } = ctx;
const modal = document.getElementById('delete-item-modal');
const idEl = document.getElementById('delete-item-id');
const posterEl = document.getElementById('delete-item-poster');
const confirmBtn = document.getElementById('delete-item-confirm');
const cancelBtn = document.getElementById('delete-item-cancel');
if (modal) {
idEl.textContent = postid;
posterEl.textContent = poster || 'unknown';
modal.style.display = 'flex';
const closeModal = () => {
modal.style.display = 'none';
confirmBtn.onclick = null;
cancelBtn.onclick = null;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
};
cancelBtn.onclick = closeModal;
confirmBtn.onclick = async () => {
confirmBtn.textContent = 'Deleting...';
confirmBtn.disabled = true;
try {
const res = await post("/api/v2/admin/deletepost", {
postid: postid
});
if (!res.success) {
alert(res.msg);
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
} else {
closeModal();
}
} catch (e) {
alert('Error: ' + e); // Or e.message
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
};
}
};
const toggleFavEvent = async (e) => {
const ctx = getContext();
if (!ctx) return;
const { postid } = ctx;
const res = await post('/api/v2/admin/togglefav', {
postid: postid
});
if (res.success) {
const fav = document.querySelector("svg#a_favo > use").href;
fav.baseVal = '/s/img/iconset.svg#heart_' + (fav.baseVal.match(/heart_(regular|solid)$/)[1] == "solid" ? "regular" : "solid");
const favcontainer = document.querySelector('span#favs');
favcontainer.innerHTML = "";
favcontainer.hidden = !(favcontainer.hidden || res.favs.length > 0);
res.favs.forEach(f => {
const a = document.createElement('a');
a.href = `/user/${f.user}/favs`;
a.setAttribute('tooltip', f.user);
a.setAttribute('flow', 'up');
const img = document.createElement('img');
img.src = `/t/${f.avatar}.webp`;
img.style.height = "32px";
img.style.width = "32px";
a.insertAdjacentElement('beforeend', img);
favcontainer.insertAdjacentElement('beforeend', a);
favcontainer.innerHTML += "&nbsp;";
});
}
};
let tmptt = null;
const editTagEvent = async e => {
e.preventDefault();
if (e.detail === 2) { // Double click
clearTimeout(tmptt);
const old = e.target;
const parent = e.target.parentElement;
const oldtag = e.target.innerText;
const textfield = document.createElement('input');
textfield.value = e.target.innerText;
textfield.size = 10;
parent.insertAdjacentElement('afterbegin', textfield);
textfield.focus();
parent.removeChild(e.target);
// Hide delete button while editing
const delBtn = parent.querySelector('a:last-child');
if (delBtn) delBtn.style.display = 'none';
textfield.addEventListener("keyup", async e => {
if (e.key === 'Enter') {
parent.removeChild(textfield);
let res = await fetch('/api/v2/admin/tags/' + encodeURIComponent(oldtag), {
method: 'PUT',
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newtag: textfield.value })
});
const status = res.status;
res = await res.json();
switch (status) {
case 200:
case 201:
parent.insertAdjacentElement('afterbegin', old);
if (delBtn) delBtn.style.display = '';
old.href = `/tag/${res.tag}`;
old.innerText = res.tag.trim();
break;
default:
console.log(res);
break;
}
}
else if (e.key === 'Escape') {
parent.removeChild(textfield);
parent.insertAdjacentElement('afterbegin', old);
if (delBtn) delBtn.style.display = '';
}
});
}
else
tmptt = setTimeout(() => location.href = e.target.href, 250);
return false;
};
// Event Delegation
// Event Delegation
document.addEventListener("click", e => {
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
if (target.matches("a#a_addtag")) {
addtagClick(e);
} else if (target.matches("a#a_toggle")) {
toggleEvent(e);
} else if (target.closest("svg#a_favo")) {
toggleFavEvent(e);
} else if (target.closest("svg#a_delete")) {
deleteButtonEvent(e);
} else if (target.matches("#tags > .badge > a:first-child")) {
editTagEvent(e);
} else if (target.matches("#tags > .badge > a:last-child") || target.closest('.admin-deltag')) {
// Match by structure OR class (added in renderTags)
deleteEvent(e);
}
});
document.addEventListener("keyup", e => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
const ctx = getContext();
if (!ctx) return;
if (e.key === "p") toggleEvent();
else if (e.key === "i") addtagClick();
else if (e.key === "x") deleteButtonEvent();
else if (e.key === "f") toggleFavEvent();
});
// Settings page
if (document.location.pathname === '/settings') {
const saveAvatar = async e => {
e.preventDefault();
const avatar = +document.querySelector('input[name="i_avatar"]').value;
let res = await fetch('/api/v2/settings/setAvatar', {
method: 'PUT',
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatar })
});
const code = res.status;
res = await res.json();
if (code === 200) {
document.querySelector('#img_avatar').src = `/t/${avatar}.webp`;
document.querySelector('img.avatar').src = `/t/${avatar}.webp`;
}
};
const sAvatar = document.querySelector('input#s_avatar');
if (sAvatar) sAvatar.addEventListener('click', saveAvatar);
const iAvatar = document.querySelector('input[name="i_avatar"]');
if (iAvatar) iAvatar.addEventListener('keyup', async e => { if (e.key === 'Enter') await saveAvatar(e); });
}
})();