feat: add emoji reaction support for files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m46s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m46s
- Implement `ReactionService` to manage file reactions in the database.
- Add `POST /d/{boxID}/f/{fileID}/react` endpoint to handle user reactions.
- Add `GET /emoji/{pack}/{file}` endpoint to serve custom emoji assets.
- Support loading custom emoji packs dynamically from the data directory.
- Update README with instructions on configuring emoji reaction packs.
This commit is contained in:
@@ -65,6 +65,242 @@
|
||||
|
||||
.file-card {
|
||||
position: relative;
|
||||
padding-bottom: 2.6rem;
|
||||
}
|
||||
|
||||
.file-reaction-dock {
|
||||
position: absolute;
|
||||
right: 0.65rem;
|
||||
bottom: 0.55rem;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
max-width: calc(100% - 1.3rem);
|
||||
gap: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-reactions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reaction-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
min-height: 1.6rem;
|
||||
padding: 0.16rem 0.38rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--card) 88%, #000);
|
||||
color: var(--foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reaction-pill img {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reaction-button {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--card) 92%, #000);
|
||||
color: var(--foreground);
|
||||
opacity: 0;
|
||||
transform: translateY(0.3rem) scale(0.94);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
|
||||
transition: opacity 150ms ease, transform 150ms ease, border-color 150ms ease, background 150ms ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reaction-button svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.file-card:hover .reaction-button,
|
||||
.file-card:focus-within .reaction-button,
|
||||
.reaction-button:focus-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.reaction-button:hover,
|
||||
.reaction-button:focus-visible {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.reaction-picker {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 70;
|
||||
width: min(21rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
html.reaction-picker-open,
|
||||
html.reaction-picker-open body {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.reaction-picker[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-picker.is-mobile {
|
||||
inset: 0;
|
||||
width: auto;
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
overflow: hidden;
|
||||
padding: 0.75rem 0.75rem max(1.5rem, env(safe-area-inset-bottom));
|
||||
background: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.reaction-picker-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 97%, #000);
|
||||
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.52);
|
||||
}
|
||||
|
||||
.reaction-picker.is-mobile .reaction-picker-panel {
|
||||
width: min(100%, 34rem);
|
||||
height: 75dvh;
|
||||
max-height: 75dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reaction-picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reaction-picker-close {
|
||||
min-height: 2rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.reaction-picker-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.55rem 0.7rem 0;
|
||||
}
|
||||
|
||||
.reaction-tab {
|
||||
flex: 0 0 auto;
|
||||
min-height: 1.8rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reaction-tab.is-active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.reaction-search {
|
||||
display: block;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.reaction-search input {
|
||||
width: 100%;
|
||||
min-height: 2.15rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
|
||||
.reaction-grid-wrap {
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
padding: 0 0.7rem 0.7rem;
|
||||
}
|
||||
|
||||
.reaction-picker.is-mobile .reaction-grid-wrap {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.reaction-grid {
|
||||
display: none;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.reaction-grid.is-active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.reaction-picker.is-mobile .reaction-grid {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
padding: 0.18rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.reaction-emoji:hover,
|
||||
.reaction-emoji:focus-visible {
|
||||
border-color: var(--border);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.reaction-emoji[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-emoji img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
|
||||
@@ -220,6 +220,16 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-reaction-dock {
|
||||
right: 0.5rem;
|
||||
bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.reaction-button {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
198
backend/static/js/12-reactions.js
Normal file
198
backend/static/js/12-reactions.js
Normal file
@@ -0,0 +1,198 @@
|
||||
(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 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);
|
||||
});
|
||||
});
|
||||
|
||||
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 || !activeButton || !activeCard) {
|
||||
return;
|
||||
}
|
||||
await submitReaction(emoji);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
closePicker();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closePicker();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (activeButton && !picker.hidden) {
|
||||
positionPicker(activeButton);
|
||||
}
|
||||
});
|
||||
|
||||
function openPicker(button) {
|
||||
activeButton = button;
|
||||
activeCard = button.closest("[data-reaction-card]");
|
||||
picker.hidden = false;
|
||||
picker.classList.add("is-open");
|
||||
if (search) {
|
||||
search.value = "";
|
||||
filterEmoji("");
|
||||
}
|
||||
positionPicker(button);
|
||||
}
|
||||
|
||||
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 = "";
|
||||
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 submitReaction(emoji) {
|
||||
const body = new URLSearchParams();
|
||||
body.set("emoji_id", emoji.dataset.emojiId);
|
||||
|
||||
activeButton.disabled = true;
|
||||
const response = await fetch(activeButton.dataset.reactUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
activeButton.disabled = false;
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
renderReactions(activeCard, payload.reactions || []);
|
||||
activeButton.remove();
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function renderReactions(card, reactions) {
|
||||
const list = card.querySelector("[data-reaction-list]");
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.replaceChildren();
|
||||
reactions.forEach((reaction) => {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "reaction-pill";
|
||||
pill.title = 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);
|
||||
list.append(pill);
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user