- Limit the number of initially visible reactions per file to 2 and calculate the overflow count on the backend. - Redesign the retro theme CSS to mimic a classic Windows 98 Explorer window, including title bars, toolbars, and sunken panes. - Add local storage persistence for the file browser view preference (list vs. thumbnails).
305 lines
9.3 KiB
JavaScript
305 lines
9.3 KiB
JavaScript
(function () {
|
|
const picker = document.querySelector("[data-reaction-picker]");
|
|
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
|
|
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
|
|
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
|
|
const existingSection = picker ? picker.querySelector("[data-reaction-existing]") : null;
|
|
const existingList = picker ? picker.querySelector("[data-reaction-existing-list]") : null;
|
|
const readonlyNote = picker ? picker.querySelector("[data-reaction-readonly]") : null;
|
|
const chooserElements = picker ? Array.from(picker.querySelectorAll(".reaction-picker-tabs, .reaction-search, .reaction-grid-wrap")) : [];
|
|
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
|
|
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
|
|
|
|
let activeButton = null;
|
|
let activeCard = null;
|
|
|
|
document.querySelectorAll("[data-reaction-button]").forEach((button) => {
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openPicker(button);
|
|
});
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const pill = event.target.closest("[data-reaction-pill]");
|
|
if (pill) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const card = pill.closest("[data-reaction-card]") || activeCard;
|
|
if (!card) {
|
|
return;
|
|
}
|
|
if (card.dataset.reacted === "true") {
|
|
openPickerForCard(card, pill);
|
|
return;
|
|
}
|
|
submitReactionForCard(card, pill.dataset.reactionEmojiId);
|
|
return;
|
|
}
|
|
|
|
const more = event.target.closest("[data-reaction-more]");
|
|
if (!more) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const card = more.closest("[data-reaction-card]");
|
|
if (card) {
|
|
openPickerForCard(card, more);
|
|
}
|
|
});
|
|
|
|
if (!picker || !panel) {
|
|
return;
|
|
}
|
|
|
|
// Aurora's glass card uses backdrop-filter, and the main content animates
|
|
// with transform. Both can create a containing block for fixed descendants,
|
|
// so keep the floating picker at body level where viewport coordinates mean
|
|
// what they say.
|
|
document.body.appendChild(picker);
|
|
|
|
picker.addEventListener("click", (event) => {
|
|
if (event.target === picker) {
|
|
closePicker();
|
|
}
|
|
});
|
|
|
|
panel.addEventListener("click", async (event) => {
|
|
const emoji = event.target.closest("[data-emoji-id]");
|
|
if (!emoji || !activeCard || activeCard.dataset.reacted === "true") {
|
|
return;
|
|
}
|
|
await submitReactionForCard(activeCard, emoji.dataset.emojiId);
|
|
});
|
|
|
|
tabs.forEach((tab) => {
|
|
tab.addEventListener("click", () => {
|
|
setActiveTab(tab.dataset.reactionTab);
|
|
});
|
|
});
|
|
|
|
if (search) {
|
|
search.addEventListener("input", () => filterEmoji(search.value));
|
|
}
|
|
|
|
if (closeButton) {
|
|
closeButton.addEventListener("click", closePicker);
|
|
}
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (picker.hidden) {
|
|
return;
|
|
}
|
|
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
|
|
return;
|
|
}
|
|
if (event.target.closest("[data-reaction-more]") || event.target.closest("[data-reaction-pill]")) {
|
|
return;
|
|
}
|
|
closePicker();
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
closePicker();
|
|
}
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
if (activeButton && !picker.hidden) {
|
|
positionPicker(activeButton);
|
|
}
|
|
});
|
|
|
|
function openPicker(button) {
|
|
openPickerForCard(button.closest("[data-reaction-card]"), button);
|
|
}
|
|
|
|
function openPickerForCard(card, trigger) {
|
|
if (!card) {
|
|
return;
|
|
}
|
|
activeButton = trigger || card.querySelector("[data-reaction-button]");
|
|
activeCard = card;
|
|
populateExistingReactions(card);
|
|
setPickerReadonly(card.dataset.reacted === "true");
|
|
picker.hidden = false;
|
|
picker.classList.add("is-open");
|
|
if (search) {
|
|
search.value = "";
|
|
filterEmoji("");
|
|
}
|
|
positionPicker(activeButton || card);
|
|
}
|
|
|
|
function closePicker() {
|
|
picker.hidden = true;
|
|
picker.classList.remove("is-open", "is-mobile");
|
|
document.documentElement.classList.remove("reaction-picker-open");
|
|
picker.style.left = "";
|
|
picker.style.top = "";
|
|
setPickerReadonly(false);
|
|
activeButton = null;
|
|
activeCard = null;
|
|
}
|
|
|
|
function positionPicker(button) {
|
|
if (isMobilePicker()) {
|
|
picker.classList.add("is-mobile");
|
|
document.documentElement.classList.add("reaction-picker-open");
|
|
picker.style.left = "0px";
|
|
picker.style.top = "0px";
|
|
return;
|
|
}
|
|
|
|
picker.classList.remove("is-mobile");
|
|
document.documentElement.classList.remove("reaction-picker-open");
|
|
picker.style.left = "0px";
|
|
picker.style.top = "0px";
|
|
const buttonRect = button.getBoundingClientRect();
|
|
const pickerRect = panel.getBoundingClientRect();
|
|
const margin = 10;
|
|
const preferredLeft = buttonRect.left + (buttonRect.width / 2) - (pickerRect.width / 2);
|
|
const preferredTop = buttonRect.bottom + 8;
|
|
const left = Math.min(Math.max(margin, preferredLeft), window.innerWidth - pickerRect.width - margin);
|
|
const top = Math.min(Math.max(margin, preferredTop), window.innerHeight - pickerRect.height - margin);
|
|
picker.style.left = `${left}px`;
|
|
picker.style.top = `${top}px`;
|
|
}
|
|
|
|
function isMobilePicker() {
|
|
return window.matchMedia("(max-width: 820px), (pointer: coarse)").matches;
|
|
}
|
|
|
|
function setActiveTab(tabID) {
|
|
tabs.forEach((tab) => {
|
|
const active = tab.dataset.reactionTab === tabID;
|
|
tab.classList.toggle("is-active", active);
|
|
tab.setAttribute("aria-selected", active ? "true" : "false");
|
|
});
|
|
panels.forEach((item) => {
|
|
item.classList.toggle("is-active", item.dataset.reactionPanel === tabID);
|
|
});
|
|
}
|
|
|
|
function filterEmoji(value) {
|
|
const query = value.trim().toLowerCase();
|
|
picker.querySelectorAll("[data-emoji-id]").forEach((button) => {
|
|
const haystack = `${button.dataset.emojiId} ${button.dataset.emojiLabel}`.toLowerCase();
|
|
button.hidden = query !== "" && !haystack.includes(query);
|
|
});
|
|
}
|
|
|
|
async function submitReactionForCard(card, emojiID) {
|
|
if (!card || !emojiID || card.dataset.reacted === "true") {
|
|
return;
|
|
}
|
|
const body = new URLSearchParams();
|
|
body.set("emoji_id", emojiID);
|
|
|
|
const reactButton = card.querySelector("[data-reaction-button]");
|
|
if (reactButton) {
|
|
reactButton.disabled = true;
|
|
}
|
|
const response = await fetch(card.dataset.reactUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (reactButton) {
|
|
reactButton.disabled = false;
|
|
}
|
|
closePicker();
|
|
return;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
renderReactions(card, payload.reactions || []);
|
|
card.dataset.reacted = "true";
|
|
if (reactButton) {
|
|
reactButton.remove();
|
|
}
|
|
closePicker();
|
|
}
|
|
|
|
function renderReactions(card, reactions) {
|
|
const list = card.querySelector("[data-reaction-list]");
|
|
if (!list) {
|
|
return;
|
|
}
|
|
list.replaceChildren();
|
|
reactions.forEach((reaction) => {
|
|
const pill = buildReactionPill(reaction);
|
|
if (!reaction.visible) {
|
|
pill.classList.add("is-hidden-summary");
|
|
}
|
|
list.append(pill);
|
|
});
|
|
const hiddenCount = reactions.length > 2 ? reactions.length - 2 : 0;
|
|
if (hiddenCount > 0) {
|
|
const more = document.createElement("button");
|
|
more.className = "reaction-more";
|
|
more.type = "button";
|
|
more.dataset.reactionMore = "";
|
|
more.textContent = `+${hiddenCount}`;
|
|
more.setAttribute("aria-label", `Show ${hiddenCount} more reactions`);
|
|
list.append(more);
|
|
}
|
|
}
|
|
|
|
function buildReactionPill(reaction) {
|
|
const pill = document.createElement("button");
|
|
pill.className = "reaction-pill";
|
|
pill.type = "button";
|
|
pill.title = reaction.label || reaction.emojiId;
|
|
pill.dataset.reactionPill = "";
|
|
pill.dataset.reactionEmojiId = reaction.emojiId;
|
|
pill.dataset.reactionLabel = reaction.label || reaction.emojiId;
|
|
pill.dataset.reactionUrl = reaction.url;
|
|
pill.dataset.reactionCount = reaction.count;
|
|
pill.setAttribute("aria-label", `React with ${reaction.label || reaction.emojiId}`);
|
|
|
|
const image = document.createElement("img");
|
|
image.src = reaction.url;
|
|
image.alt = reaction.label || reaction.emojiId;
|
|
image.loading = "lazy";
|
|
|
|
const count = document.createElement("span");
|
|
count.textContent = reaction.count;
|
|
|
|
pill.append(image, count);
|
|
return pill;
|
|
}
|
|
|
|
function populateExistingReactions(card) {
|
|
if (!existingSection || !existingList) {
|
|
return;
|
|
}
|
|
existingList.replaceChildren();
|
|
card.querySelectorAll("[data-reaction-pill]").forEach((pill) => {
|
|
const clone = pill.cloneNode(true);
|
|
clone.classList.remove("is-hidden-summary");
|
|
existingList.append(clone);
|
|
});
|
|
existingSection.hidden = existingList.children.length === 0;
|
|
}
|
|
|
|
function setPickerReadonly(readonly) {
|
|
picker.classList.toggle("is-readonly", readonly);
|
|
chooserElements.forEach((element) => {
|
|
element.hidden = readonly;
|
|
});
|
|
if (readonlyNote) {
|
|
readonlyNote.hidden = !readonly;
|
|
}
|
|
}
|
|
})();
|