mirror of
https://github.com/ClaperCo/Claper.git
synced 2026-08-29 10:09:08 +02:00
Revamp attendee room UI and add reactions
This commit is contained in:
@@ -68,6 +68,98 @@
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.phx-disconnected #connection-status {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attendee-composer {
|
||||
background: linear-gradient(
|
||||
333deg,
|
||||
rgba(17, 134, 213, 0.8) 0%,
|
||||
rgba(163, 39, 255, 0.8) 100%
|
||||
);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.32);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
}
|
||||
|
||||
#focus-slot:fullscreen {
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#focus-slot.focus-fallback-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
max-height: none;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#focus-slot.focus-collapsed {
|
||||
height: 3rem;
|
||||
min-height: 3rem;
|
||||
max-height: 3rem;
|
||||
}
|
||||
|
||||
#focus-slot.focus-collapsed #focus-media,
|
||||
#focus-slot.focus-collapsed #focus-captions,
|
||||
#focus-slot.focus-collapsed [data-focus-collapse],
|
||||
#focus-slot.focus-collapsed [data-focus-fullscreen] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#focus-slot.focus-collapsed [data-focus-collapsed-bar] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#focus-slot:fullscreen #focus-media {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#chat-feed {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(75 85 99) transparent;
|
||||
}
|
||||
|
||||
@media (max-height: 500px) {
|
||||
#focus-slot {
|
||||
height: 7rem;
|
||||
min-height: 7rem;
|
||||
}
|
||||
|
||||
#chat-feed {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
#chat-feed[data-room-reactions="true"] {
|
||||
padding-bottom: 10rem;
|
||||
}
|
||||
|
||||
#room-composer textarea {
|
||||
max-height: 2.75rem;
|
||||
}
|
||||
|
||||
#room-reaction-fab [data-reaction-picker] {
|
||||
position: fixed;
|
||||
left: 4.75rem;
|
||||
bottom: 4.75rem;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#focus-slot,
|
||||
#new-interaction-badge,
|
||||
.react-animation {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
color: var(--color-supporting-red-700);
|
||||
display: block;
|
||||
@@ -369,14 +461,17 @@
|
||||
}}
|
||||
.react-animation {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.react-animation:nth-child(odd) {
|
||||
animation: react 2s linear;
|
||||
.react-animation--short {
|
||||
animation: react 2s linear forwards;
|
||||
}
|
||||
|
||||
.react-animation:nth-child(even) {
|
||||
animation: react2 2s linear;
|
||||
.react-animation--long {
|
||||
animation: react2 2s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes react {
|
||||
|
||||
530
assets/js/app.js
530
assets/js/app.js
@@ -188,6 +188,80 @@ Hooks.Scroll = {
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.RoomFeed = {
|
||||
mounted() {
|
||||
this.chip = document.querySelector(this.el.dataset.chip);
|
||||
this.unread = 0;
|
||||
this.atBottom = true;
|
||||
this.onScroll = () => {
|
||||
this.atBottom = this.distanceFromBottom() <= 48;
|
||||
if (this.atBottom) this.clearUnread();
|
||||
};
|
||||
this.onChipClick = () => this.scrollToBottom(true);
|
||||
this.onMessageSent = () => {
|
||||
this.sentMessage = true;
|
||||
this.scrollToBottom(true);
|
||||
clearTimeout(this.sentMessageTimeout);
|
||||
this.sentMessageTimeout = setTimeout(() => {
|
||||
this.sentMessage = false;
|
||||
}, 1000);
|
||||
};
|
||||
this.el.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
this.el.addEventListener("room:message-sent", this.onMessageSent);
|
||||
this.chip?.addEventListener("click", this.onChipClick);
|
||||
requestAnimationFrame(() => this.scrollToBottom(true));
|
||||
},
|
||||
beforeUpdate() {
|
||||
this.wasAtBottom = this.distanceFromBottom() <= 48;
|
||||
this.previousPostCount = this.postCount();
|
||||
},
|
||||
updated() {
|
||||
const newPostCount = this.postCount();
|
||||
const hasNewPost = newPostCount > this.previousPostCount;
|
||||
|
||||
if (hasNewPost && this.sentMessage) {
|
||||
clearTimeout(this.sentMessageTimeout);
|
||||
this.sentMessage = false;
|
||||
requestAnimationFrame(() => this.scrollToBottom(true));
|
||||
} else if (hasNewPost && this.wasAtBottom) {
|
||||
this.scrollToBottom();
|
||||
} else if (hasNewPost) {
|
||||
this.unread += newPostCount - this.previousPostCount;
|
||||
this.showUnread();
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
this.el.removeEventListener("scroll", this.onScroll);
|
||||
this.el.removeEventListener("room:message-sent", this.onMessageSent);
|
||||
this.chip?.removeEventListener("click", this.onChipClick);
|
||||
clearTimeout(this.sentMessageTimeout);
|
||||
},
|
||||
postCount() {
|
||||
return this.el.querySelectorAll(":scope > [id^='posts-']").length;
|
||||
},
|
||||
distanceFromBottom() {
|
||||
return this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight;
|
||||
},
|
||||
scrollToBottom(instant = false) {
|
||||
this.el.scrollTo({
|
||||
top: this.el.scrollHeight,
|
||||
behavior: instant ? "auto" : "smooth",
|
||||
});
|
||||
this.atBottom = true;
|
||||
this.clearUnread();
|
||||
},
|
||||
showUnread() {
|
||||
if (!this.chip) return;
|
||||
const count = this.chip.querySelector("[data-unread-count]");
|
||||
if (count) count.textContent = this.unread;
|
||||
this.chip.classList.remove("hidden");
|
||||
},
|
||||
clearUnread() {
|
||||
this.unread = 0;
|
||||
this.chip?.classList.add("hidden");
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.ScrollIntoDiv = {
|
||||
mounted() {
|
||||
let useParent = this.el.dataset.useParent === "true";
|
||||
@@ -225,45 +299,64 @@ Hooks.ScrollIntoDiv = {
|
||||
|
||||
Hooks.NicknamePicker = {
|
||||
mounted() {
|
||||
let currentNickname = localStorage.getItem("nickname") || "";
|
||||
this.storageKey = this.el.dataset.storageKey || "nickname";
|
||||
this.onClick = (event) => this.clicked(event);
|
||||
let currentNickname = this.currentNickname();
|
||||
if (currentNickname.length > 0) {
|
||||
this.pushEvent("set-nickname", { nickname: currentNickname });
|
||||
}
|
||||
|
||||
this.el.addEventListener("click", (e) => this.clicked(e));
|
||||
this.el.addEventListener("click", this.onClick);
|
||||
},
|
||||
reconnected() {
|
||||
let currentNickname = localStorage.getItem("nickname") || "";
|
||||
let currentNickname = this.currentNickname();
|
||||
if (currentNickname.length > 0) {
|
||||
this.pushEvent("set-nickname", { nickname: currentNickname });
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
this.el.removeEventListener("click", (e) => this.clicked(e));
|
||||
this.el.removeEventListener("click", this.onClick);
|
||||
},
|
||||
clicked(e) {
|
||||
let nickname = prompt(
|
||||
this.el.dataset.prompt,
|
||||
localStorage.getItem("nickname") || "",
|
||||
localStorage.getItem(this.storageKey) || "",
|
||||
);
|
||||
|
||||
if (nickname && nickname.trim().length > 0) {
|
||||
localStorage.setItem("nickname", nickname);
|
||||
this.pushEvent("set-nickname", { nickname: nickname });
|
||||
if (nickname) {
|
||||
nickname = nickname.trim();
|
||||
if (nickname.length < 2 || nickname.length > 20) {
|
||||
window.alert(this.el.dataset.invalid);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(this.storageKey, nickname);
|
||||
this.pushEvent("set-nickname", { nickname });
|
||||
this.js().exec(this.el.dataset.close);
|
||||
}
|
||||
},
|
||||
currentNickname() {
|
||||
const scopedNickname = localStorage.getItem(this.storageKey);
|
||||
if (scopedNickname !== null) return scopedNickname;
|
||||
|
||||
const legacyNickname = (localStorage.getItem("nickname") || "").trim();
|
||||
localStorage.removeItem("nickname");
|
||||
if (legacyNickname.length < 2 || legacyNickname.length > 20) return "";
|
||||
|
||||
localStorage.setItem(this.storageKey, legacyNickname);
|
||||
return legacyNickname;
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.EmptyNickname = {
|
||||
mounted() {
|
||||
this.el.addEventListener("click", (e) => this.clicked(e));
|
||||
this.onClick = () => {
|
||||
localStorage.removeItem(this.el.dataset.storageKey || "nickname");
|
||||
localStorage.removeItem("nickname");
|
||||
};
|
||||
this.el.addEventListener("click", this.onClick);
|
||||
},
|
||||
destroyed() {
|
||||
this.el.removeEventListener("click", (e) => this.clicked(e));
|
||||
},
|
||||
clicked(e) {
|
||||
localStorage.removeItem("nickname");
|
||||
this.el.removeEventListener("click", this.onClick);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -281,63 +374,71 @@ Hooks.SearchableSelect = {
|
||||
};
|
||||
|
||||
Hooks.PostForm = {
|
||||
onPress(e, submitBtn, TA) {
|
||||
if (e.key == "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitBtn.click();
|
||||
} else {
|
||||
if (TA.value.length > 0 && TA.value.length < 256) {
|
||||
submitBtn.classList.remove("opacity-50");
|
||||
submitBtn.classList.add("opacity-100");
|
||||
submitBtn.disabled = false;
|
||||
} else {
|
||||
submitBtn.classList.add("opacity-50");
|
||||
submitBtn.classList.remove("opacity-100");
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
onSubmit(e, TA) {
|
||||
e.preventDefault();
|
||||
document.getElementById("hiddenSubmit").click();
|
||||
TA.value = "";
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
const submitBtn = document.getElementById("submitBtn");
|
||||
const TA = document.getElementById("postFormTA");
|
||||
if (submitBtn && TA) {
|
||||
submitBtn.addEventListener("click", (e) => this.onSubmit(e, TA));
|
||||
TA.addEventListener("keydown", (e) => this.onPress(e, submitBtn, TA));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// set nickname if present
|
||||
let nickname = this.el.dataset.nickname;
|
||||
if (nickname) {
|
||||
localStorage.setItem("nickname", nickname);
|
||||
}
|
||||
this.storageKey = this.el.dataset.storageKey;
|
||||
this.bindElements();
|
||||
this.restoreDraft();
|
||||
this.handleEvent("post-saved", () => {
|
||||
this.ta.value = "";
|
||||
localStorage.removeItem(this.storageKey);
|
||||
this.updateState();
|
||||
document
|
||||
.querySelector("#chat-feed")
|
||||
?.dispatchEvent(new CustomEvent("room:message-sent"));
|
||||
});
|
||||
},
|
||||
updated() {
|
||||
const submitBtn = document.getElementById("submitBtn");
|
||||
const TA = document.getElementById("postFormTA");
|
||||
if (TA.value.length > 0 && TA.value.length < 256) {
|
||||
submitBtn.classList.remove("opacity-50");
|
||||
submitBtn.classList.add("opacity-100");
|
||||
submitBtn.disabled = false;
|
||||
} else {
|
||||
submitBtn.classList.add("opacity-50");
|
||||
submitBtn.classList.remove("opacity-100");
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
this.bindElements();
|
||||
this.restoreDraft();
|
||||
this.updateState();
|
||||
},
|
||||
destroyed() {
|
||||
const submitBtn = document.getElementById("submitBtn");
|
||||
const TA = document.getElementById("postFormTA");
|
||||
if (submitBtn && TA) {
|
||||
TA.removeEventListener("keydown", (e) => this.onPress(e, submitBtn, TA));
|
||||
submitBtn.removeEventListener("click", (e) => this.onSubmit(e, TA));
|
||||
}
|
||||
this.unbindElements();
|
||||
},
|
||||
bindElements() {
|
||||
const ta = this.el.querySelector("#postFormTA");
|
||||
const submit = this.el.querySelector("#submitBtn");
|
||||
if (this.ta === ta && this.submit === submit) return;
|
||||
|
||||
this.unbindElements();
|
||||
this.ta = ta;
|
||||
this.submit = submit;
|
||||
if (!this.ta || !this.submit) return;
|
||||
|
||||
this.onInput = () => {
|
||||
localStorage.setItem(this.storageKey, this.ta.value);
|
||||
this.updateState();
|
||||
};
|
||||
this.onKeyDown = (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && this.valid()) {
|
||||
event.preventDefault();
|
||||
this.el.requestSubmit();
|
||||
}
|
||||
};
|
||||
this.ta.addEventListener("input", this.onInput);
|
||||
this.ta.addEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
unbindElements() {
|
||||
this.ta?.removeEventListener("input", this.onInput);
|
||||
this.ta?.removeEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
restoreDraft() {
|
||||
if (!this.ta || !this.storageKey || this.ta.value) return;
|
||||
this.ta.value = localStorage.getItem(this.storageKey) || "";
|
||||
this.updateState();
|
||||
},
|
||||
valid() {
|
||||
const length = this.ta?.value.trim().length || 0;
|
||||
return !this.ta?.disabled && length >= 2 && length <= 255;
|
||||
},
|
||||
updateState() {
|
||||
if (!this.ta || !this.submit) return;
|
||||
const valid = this.valid();
|
||||
this.submit.disabled = !valid;
|
||||
this.submit.classList.toggle("opacity-50", !valid);
|
||||
this.submit.classList.toggle("opacity-100", valid);
|
||||
this.ta.style.height = "auto";
|
||||
this.ta.style.height = `${Math.min(this.ta.scrollHeight, 64)}px`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -523,45 +624,296 @@ Hooks.OpenPresenter = {
|
||||
this.el.removeEventListener("click", (e) => this.open(e));
|
||||
},
|
||||
};
|
||||
Hooks.GlobalReacts = {
|
||||
svgCache: {},
|
||||
|
||||
Hooks.AttendeeFocus = {
|
||||
mounted() {
|
||||
this.preloadSVGs();
|
||||
this.handleEvent("global-react", (data) => {
|
||||
const svgContent = this.svgCache[data.type];
|
||||
if (svgContent) {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = svgContent;
|
||||
const svgElement = container.firstChild;
|
||||
svgElement.classList.add(
|
||||
"react-animation",
|
||||
"absolute",
|
||||
"transform",
|
||||
"opacity-0",
|
||||
);
|
||||
svgElement.classList.add(...this.el.className.split(" "));
|
||||
this.el.appendChild(svgElement);
|
||||
this.focusKey = this.el.dataset.focusKey;
|
||||
this.captionKey = "attendee-captions";
|
||||
this.collapseKey = this.el.dataset.collapseKey;
|
||||
this.onClick = (event) => {
|
||||
if (event.target.closest("[data-focus-collapse]")) {
|
||||
localStorage.setItem(this.collapseKey, "collapsed");
|
||||
this.restoreCollapse();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-focus-show]")) {
|
||||
localStorage.setItem(this.collapseKey, "expanded");
|
||||
this.restoreCollapse();
|
||||
return;
|
||||
}
|
||||
|
||||
const fullscreenButton = event.target.closest("[data-focus-fullscreen]");
|
||||
if (fullscreenButton) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen?.();
|
||||
} else if (this.el.classList.contains("focus-fallback-fullscreen")) {
|
||||
this.el.classList.remove("focus-fallback-fullscreen");
|
||||
} else {
|
||||
this.enterFullscreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-caption-toggle]")) {
|
||||
const captions = this.el.querySelector("[data-caption-text]");
|
||||
if (!captions) return;
|
||||
const hidden = captions.classList.toggle("invisible");
|
||||
localStorage.setItem(this.captionKey, hidden ? "hidden" : "visible");
|
||||
}
|
||||
};
|
||||
this.onKeyDown = (event) => {
|
||||
if (event.key === "Escape") this.el.classList.remove("focus-fallback-fullscreen");
|
||||
};
|
||||
this.el.addEventListener("click", this.onClick);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
this.restoreCaptions();
|
||||
this.restoreCollapse();
|
||||
},
|
||||
updated() {
|
||||
const nextKey = this.el.dataset.focusKey;
|
||||
if (this.focusKey !== nextKey) {
|
||||
const composer = document.querySelector("#postFormTA");
|
||||
const badge = this.el.querySelector("#new-interaction-badge");
|
||||
if (composer && document.activeElement === composer && badge) {
|
||||
badge.classList.remove("hidden");
|
||||
window.setTimeout(() => badge.classList.add("hidden"), 3000);
|
||||
}
|
||||
this.focusKey = nextKey;
|
||||
}
|
||||
this.restoreCaptions();
|
||||
this.restoreCollapse();
|
||||
},
|
||||
destroyed() {
|
||||
this.el.removeEventListener("click", this.onClick);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
restoreCaptions() {
|
||||
if (localStorage.getItem(this.captionKey) === "hidden") {
|
||||
this.el.querySelector("[data-caption-text]")?.classList.add("invisible");
|
||||
}
|
||||
},
|
||||
restoreCollapse() {
|
||||
const interactionMode = this.el.dataset.interactionMode === "true";
|
||||
const collapsed = localStorage.getItem(this.collapseKey) === "collapsed";
|
||||
this.el.classList.toggle("focus-collapsed", collapsed && !interactionMode);
|
||||
},
|
||||
enterFullscreen() {
|
||||
if (!this.el.requestFullscreen) {
|
||||
this.el.classList.add("focus-fallback-fullscreen");
|
||||
return;
|
||||
}
|
||||
|
||||
this.el.requestFullscreen().catch(() => {
|
||||
this.el.classList.add("focus-fallback-fullscreen");
|
||||
});
|
||||
this.handleEvent("reset-global-react", (data) => {
|
||||
this.el.innerHTML = "";
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.RoomReactionFab = {
|
||||
mounted() {
|
||||
this.trigger = this.el.querySelector("[data-reaction-trigger]");
|
||||
this.picker = this.el.querySelector("[data-reaction-picker]");
|
||||
this.icon = this.el.querySelector("[data-reaction-icon]");
|
||||
this.reaction = "heart";
|
||||
this.updateIcon();
|
||||
|
||||
this.onPointerDown = () => {
|
||||
this.longPressed = false;
|
||||
this.pressTimer = window.setTimeout(() => {
|
||||
this.longPressed = true;
|
||||
this.openPicker();
|
||||
}, 450);
|
||||
};
|
||||
this.onPointerUp = () => {
|
||||
window.clearTimeout(this.pressTimer);
|
||||
if (!this.longPressed) this.send(this.reaction);
|
||||
};
|
||||
this.onPointerCancel = () => window.clearTimeout(this.pressTimer);
|
||||
this.onClick = (event) => {
|
||||
if (event.detail === 0) this.send(this.reaction);
|
||||
};
|
||||
this.onKeyDown = (event) => {
|
||||
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
this.openPicker();
|
||||
this.picker.querySelector("[data-reaction]")?.focus();
|
||||
} else if (event.key === "Escape") {
|
||||
this.closePicker();
|
||||
}
|
||||
};
|
||||
this.onPickerClick = (event) => {
|
||||
const button = event.target.closest("[data-reaction]");
|
||||
if (!button) return;
|
||||
this.send(button.dataset.reaction);
|
||||
this.closePicker();
|
||||
this.trigger.focus();
|
||||
};
|
||||
this.onPickerKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
this.closePicker();
|
||||
this.trigger.focus();
|
||||
}
|
||||
};
|
||||
|
||||
this.trigger.addEventListener("pointerdown", this.onPointerDown);
|
||||
this.trigger.addEventListener("pointerup", this.onPointerUp);
|
||||
this.trigger.addEventListener("pointercancel", this.onPointerCancel);
|
||||
this.trigger.addEventListener("click", this.onClick);
|
||||
this.trigger.addEventListener("keydown", this.onKeyDown);
|
||||
this.picker.addEventListener("click", this.onPickerClick);
|
||||
this.picker.addEventListener("keydown", this.onPickerKeyDown);
|
||||
},
|
||||
destroyed() {
|
||||
window.clearTimeout(this.pressTimer);
|
||||
this.trigger.removeEventListener("pointerdown", this.onPointerDown);
|
||||
this.trigger.removeEventListener("pointerup", this.onPointerUp);
|
||||
this.trigger.removeEventListener("pointercancel", this.onPointerCancel);
|
||||
this.trigger.removeEventListener("click", this.onClick);
|
||||
this.trigger.removeEventListener("keydown", this.onKeyDown);
|
||||
this.picker.removeEventListener("click", this.onPickerClick);
|
||||
this.picker.removeEventListener("keydown", this.onPickerKeyDown);
|
||||
},
|
||||
send(reaction) {
|
||||
this.reaction = reaction;
|
||||
this.updateIcon();
|
||||
this.pushEvent("global-react", { type: reaction });
|
||||
},
|
||||
updateIcon() {
|
||||
this.icon.src = `/images/icons/${this.reaction}.svg`;
|
||||
},
|
||||
openPicker() {
|
||||
this.picker.classList.remove("hidden");
|
||||
this.picker.classList.add("flex");
|
||||
this.trigger.setAttribute("aria-expanded", "true");
|
||||
},
|
||||
closePicker() {
|
||||
this.picker.classList.add("hidden");
|
||||
this.picker.classList.remove("flex");
|
||||
this.trigger.setAttribute("aria-expanded", "false");
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.GlobalReacts = {
|
||||
mounted() {
|
||||
this.svgCache = {};
|
||||
this.queue = [];
|
||||
this.activeReactions = new Map();
|
||||
this.sequence = 0;
|
||||
this.drainTimer = null;
|
||||
this.preloadSVGs();
|
||||
|
||||
this.globalReactRef = this.handleEvent("global-react", (data) => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (!this.svgTypes.includes(data.type)) return;
|
||||
|
||||
this.queue.push(data.type);
|
||||
if (this.queue.length > 30) this.queue.shift();
|
||||
this.scheduleDrain(0);
|
||||
});
|
||||
this.resetGlobalReactRef = this.handleEvent("reset-global-react", () => this.reset());
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.removeHandleEvent?.(this.globalReactRef);
|
||||
this.removeHandleEvent?.(this.resetGlobalReactRef);
|
||||
this.reset();
|
||||
},
|
||||
|
||||
get svgTypes() {
|
||||
return ["heart", "hundred", "clap", "raisehand"];
|
||||
},
|
||||
|
||||
preloadSVGs() {
|
||||
const svgTypes = ["heart", "hundred", "clap", "raisehand"];
|
||||
svgTypes.forEach((type) => {
|
||||
this.svgTypes.forEach((type) => {
|
||||
fetch(`/images/icons/${type}.svg`)
|
||||
.then((response) => response.text())
|
||||
.then((svgContent) => {
|
||||
this.svgCache[type] = svgContent;
|
||||
this.scheduleDrain(0);
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error(`Error loading SVG for ${type}:`, error),
|
||||
);
|
||||
.catch((error) => {
|
||||
this.svgCache[type] = null;
|
||||
this.queue = this.queue.filter((queuedType) => queuedType !== type);
|
||||
console.error(`Error loading SVG for ${type}:`, error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
scheduleDrain(delay = 120) {
|
||||
if (this.drainTimer !== null || this.queue.length === 0) return;
|
||||
|
||||
this.drainTimer = window.setTimeout(() => {
|
||||
this.drainTimer = null;
|
||||
this.drain();
|
||||
}, delay);
|
||||
},
|
||||
|
||||
drain() {
|
||||
if (this.queue.length === 0) return;
|
||||
if (this.activeReactions.size >= 12) {
|
||||
this.scheduleDrain(100);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.queue[0];
|
||||
const svgContent = this.svgCache[type];
|
||||
|
||||
if (svgContent === undefined) {
|
||||
this.scheduleDrain(100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue.shift();
|
||||
if (svgContent) this.play(svgContent);
|
||||
this.scheduleDrain();
|
||||
},
|
||||
|
||||
play(svgContent) {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = svgContent;
|
||||
const svgElement = container.firstElementChild;
|
||||
if (!svgElement) return;
|
||||
|
||||
const animationClass =
|
||||
this.sequence++ % 2 === 0 ? "react-animation--short" : "react-animation--long";
|
||||
|
||||
svgElement.classList.add(
|
||||
"react-animation",
|
||||
animationClass,
|
||||
"absolute",
|
||||
"transform",
|
||||
"opacity-0",
|
||||
...(this.el.dataset.className || "h-12 w-12").split(" "),
|
||||
);
|
||||
svgElement.style.left = `${15 + Math.random() * 70}%`;
|
||||
svgElement.style.bottom = "1rem";
|
||||
|
||||
let removalTimer;
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(removalTimer);
|
||||
this.activeReactions.delete(svgElement);
|
||||
svgElement.remove();
|
||||
this.scheduleDrain(0);
|
||||
};
|
||||
|
||||
svgElement.addEventListener("animationend", cleanup, { once: true });
|
||||
removalTimer = window.setTimeout(cleanup, 2300);
|
||||
this.activeReactions.set(svgElement, removalTimer);
|
||||
this.el.appendChild(svgElement);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.queue = [];
|
||||
window.clearTimeout(this.drainTimer);
|
||||
this.drainTimer = null;
|
||||
this.activeReactions?.forEach((timer, element) => {
|
||||
window.clearTimeout(timer);
|
||||
element.remove();
|
||||
});
|
||||
this.activeReactions?.clear();
|
||||
this.el.innerHTML = "";
|
||||
},
|
||||
};
|
||||
Hooks.WelcomeEarly = {
|
||||
mounted() {
|
||||
|
||||
@@ -4,24 +4,25 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="font-display">
|
||||
<div
|
||||
id="collapsed-embed"
|
||||
class="bg-black py-3 px-6 text-black shadow-lg mx-auto rounded-full w-max hidden"
|
||||
class="mx-auto hidden w-max rounded-full bg-gray-900 px-5 py-3 shadow-xl ring-1 ring-white/10"
|
||||
>
|
||||
<div
|
||||
class="block w-full h-full cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
class="block h-full w-full cursor-pointer"
|
||||
phx-click={toggle_embed()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="text-white flex space-x-2 items-center">
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-6 w-6"
|
||||
class="h-5 w-5 text-primary-300"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -29,20 +30,26 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
|
||||
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-bold">{gettext("See current web content")}</span>
|
||||
<span class="text-sm font-bold">{gettext("See current web content")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div id="extended-embed" class="bg-black w-full py-3 px-6 text-black shadow-lg rounded-md">
|
||||
<div
|
||||
class="block w-full h-full cursor-pointer"
|
||||
phx-click={toggle_embed()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div id="embed-pane" class="float-right mt-2">
|
||||
<div
|
||||
id="extended-embed"
|
||||
class="w-full rounded-2xl bg-gray-900 p-4 text-gray-100 shadow-2xl ring-1 ring-white/10"
|
||||
>
|
||||
<div class="relative pr-8">
|
||||
<button
|
||||
id="embed-pane"
|
||||
type="button"
|
||||
aria-label={gettext("Close")}
|
||||
class="absolute -right-1 -top-1 grid h-8 w-8 place-items-center rounded-full text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
phx-click={toggle_embed()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -50,17 +57,22 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-gray-500 my-1">{gettext("Current web content")}</p>
|
||||
<p class="text-white text-lg font-semibold mb-4">{@embed.title}</p>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-400">{gettext("Current web content")}</p>
|
||||
<p class="mb-4 text-lg font-bold leading-snug text-white">{@embed.title}</p>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class={[
|
||||
"w-full rounded-xl",
|
||||
@embed.provider == "custom" && "overflow-x-auto",
|
||||
@embed.provider != "custom" && "aspect-video overflow-hidden bg-black"
|
||||
]}>
|
||||
<.live_component
|
||||
id="embed-component"
|
||||
module={ClaperWeb.EventLive.EmbedIframeComponent}
|
||||
provider={@embed.provider}
|
||||
content={@embed.content}
|
||||
title={@embed.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ defmodule ClaperWeb.EventLive.EmbedIframeComponent do
|
||||
<%= case @provider do %>
|
||||
<% "youtube" -> %>
|
||||
<iframe
|
||||
class="h-full w-full"
|
||||
title={@title}
|
||||
src={"https://www.youtube.com/embed/#{@content |> String.split("youtu.be/") |> Enum.at(1)}"}
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
@@ -16,6 +18,8 @@ defmodule ClaperWeb.EventLive.EmbedIframeComponent do
|
||||
</iframe>
|
||||
<% "vimeo" -> %>
|
||||
<iframe
|
||||
class="h-full w-full"
|
||||
title={@title}
|
||||
src={"https://player.vimeo.com/video/#{@content |> String.split("vimeo.com/") |> Enum.at(1)}"}
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
@@ -24,6 +28,8 @@ defmodule ClaperWeb.EventLive.EmbedIframeComponent do
|
||||
</iframe>
|
||||
<% "canva" -> %>
|
||||
<iframe
|
||||
class="h-full w-full"
|
||||
title={@title}
|
||||
src={"#{@content}?embed"}
|
||||
frameborder="0"
|
||||
allowfullscreen="allowfullscreen"
|
||||
@@ -32,6 +38,8 @@ defmodule ClaperWeb.EventLive.EmbedIframeComponent do
|
||||
</iframe>
|
||||
<% "googleslides" -> %>
|
||||
<iframe
|
||||
class="h-full w-full"
|
||||
title={@title}
|
||||
src={"#{@content |> String.replace("/pub", "/embed")}"}
|
||||
frameborder="0"
|
||||
allowfullscreen="allowfullscreen"
|
||||
|
||||
@@ -3,17 +3,25 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns = assign_new(assigns, :focus_mode, fn -> false end)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<div class="font-display">
|
||||
<div
|
||||
:if={!@focus_mode}
|
||||
id="collapsed-form"
|
||||
class="bg-black py-3 px-6 text-black shadow-lg mx-auto rounded-full w-max hidden"
|
||||
class="mx-auto hidden w-max rounded-full bg-gray-900 px-5 py-3 shadow-xl ring-1 ring-white/10"
|
||||
>
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_form()} phx-target={@myself}>
|
||||
<div class="text-white flex space-x-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="block h-full w-full cursor-pointer"
|
||||
phx-click={toggle_form()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
class="h-5 w-5 text-primary-300"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
@@ -29,16 +37,31 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
<path d="M17 12h.01"></path>
|
||||
<path d="M13 12h.01"></path>
|
||||
</svg>
|
||||
<span class="font-bold">{gettext("See current form")}</span>
|
||||
<span class="text-sm font-bold">{gettext("See current form")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div id="extended-form" class="bg-black w-full py-3 px-6 text-black shadow-lg rounded-md">
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_form()} phx-target={@myself}>
|
||||
<div id="form-pane" class="float-right mt-2">
|
||||
<div
|
||||
id="extended-form"
|
||||
class={[
|
||||
"w-full rounded-2xl bg-gray-900 p-4 text-gray-100",
|
||||
@focus_mode && "shadow-none ring-0",
|
||||
!@focus_mode && "shadow-2xl ring-1 ring-white/10"
|
||||
]}
|
||||
>
|
||||
<div class="relative pr-8">
|
||||
<button
|
||||
:if={!@focus_mode}
|
||||
id="form-pane"
|
||||
type="button"
|
||||
aria-label={gettext("Close")}
|
||||
class="absolute -right-1 -top-1 grid h-8 w-8 place-items-center rounded-full text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
phx-click={toggle_form()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -46,24 +69,25 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-gray-500 my-1">{gettext("Current form")}</p>
|
||||
<p class="text-white text-lg font-semibold mb-4">{@form.title}</p>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-400">{gettext("Current form")}</p>
|
||||
<p class="mb-4 text-lg font-bold leading-snug text-white">{@form.title}</p>
|
||||
</div>
|
||||
<%= form_for :form_submit, "#", [id: @id, phx_change: "validate", phx_target: @myself, phx_submit: "submit"], fn f -> %>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<%= if (length @form.fields) > 0 do %>
|
||||
<%= for field <- @form.fields do %>
|
||||
<%= case field.type do %>
|
||||
<% "text" -> %>
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={f}
|
||||
labelClass="text-white"
|
||||
fieldClass="bg-gray-700 text-white"
|
||||
labelClass="text-gray-300"
|
||||
fieldClass="bg-gray-800 text-white border border-gray-600 !text-sm !rounded-lg"
|
||||
key={field_key(field.name)}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
readonly={not is_nil(assigns.current_form_submit)}
|
||||
value={
|
||||
if is_nil(assigns.current_form_submit),
|
||||
do: ~c"",
|
||||
@@ -73,11 +97,12 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
<% "email" -> %>
|
||||
<ClaperWeb.Component.Input.email
|
||||
form={f}
|
||||
labelClass="text-white"
|
||||
fieldClass="bg-gray-700 text-white"
|
||||
labelClass="text-gray-300"
|
||||
fieldClass="bg-gray-800 text-white border border-gray-600 !text-sm !rounded-lg"
|
||||
key={field_key(field.name)}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
readonly={not is_nil(assigns.current_form_submit)}
|
||||
value={
|
||||
if is_nil(assigns.current_form_submit),
|
||||
do: ~c"",
|
||||
@@ -89,30 +114,23 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-2 text-white font-semibold bg-primary-500 hover:bg-primary-600 rounded-md my-5"
|
||||
>
|
||||
{if is_nil(assigns.current_form_submit), do: gettext("Submit"), else: gettext("Edit")}
|
||||
</button>
|
||||
|
||||
<%= unless is_nil(assigns.current_form_submit) do %>
|
||||
<div class="flex gap-1 text-green-500 text-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M9 12l2 2l4 -4" />
|
||||
</svg>
|
||||
<span>{gettext("Saved")}</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<%= if is_nil(assigns.current_form_submit) do %>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-gradient w-full rounded-lg px-3 py-2 text-sm font-bold transition-colors"
|
||||
>
|
||||
{gettext("Submit")}
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
data-submitted
|
||||
class="w-full cursor-not-allowed rounded-lg bg-gray-700 px-3 py-2 text-sm font-bold text-gray-400"
|
||||
>
|
||||
{gettext("Submitted")}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -3,17 +3,25 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns = assign_new(assigns, :focus_mode, fn -> false end)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<div class="font-display">
|
||||
<div
|
||||
:if={!@focus_mode}
|
||||
id="collapsed-poll"
|
||||
class="bg-gray-900 py-3 px-6 text-black shadow-lg mx-auto rounded-full w-max hidden"
|
||||
class="mx-auto hidden w-max rounded-full bg-gray-900 px-5 py-3 shadow-xl ring-1 ring-white/10"
|
||||
>
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_poll()} phx-target={@myself}>
|
||||
<div class="text-white flex space-x-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="block h-full w-full cursor-pointer"
|
||||
phx-click={toggle_poll()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
class="h-5 w-5 text-primary-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -25,16 +33,31 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-bold">{gettext("See current poll")}</span>
|
||||
<span class="text-sm font-bold">{gettext("See current poll")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div id="extended-poll" class="bg-gray-900 w-full py-3 px-6 text-black shadow-lg rounded-md">
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_poll()} phx-target={@myself}>
|
||||
<div id="poll-pane" class="float-right mt-2">
|
||||
<div
|
||||
id="extended-poll"
|
||||
class={[
|
||||
"w-full rounded-2xl bg-gray-900 p-4 text-gray-100",
|
||||
@focus_mode && "shadow-none ring-0",
|
||||
!@focus_mode && "shadow-2xl ring-1 ring-white/10"
|
||||
]}
|
||||
>
|
||||
<div class="relative pr-8">
|
||||
<button
|
||||
:if={!@focus_mode}
|
||||
id="poll-pane"
|
||||
type="button"
|
||||
aria-label={gettext("Close")}
|
||||
class="absolute -right-1 -top-1 grid h-8 w-8 place-items-center rounded-full text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
phx-click={toggle_poll()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -42,78 +65,99 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<p class="text-sm text-gray-400 my-1">{gettext("Current poll")}</p>
|
||||
<p class="text-white text-xl font-semibold mb-2">{@poll.title}</p>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-400">{gettext("Current poll")}</p>
|
||||
<p class="mb-1 text-lg font-bold leading-snug text-white">{@poll.title}</p>
|
||||
<%= if @poll.multiple do %>
|
||||
<p class="text-gray-400 text-sm mb-4">{gettext("Select one or multiple options")}</p>
|
||||
<p class="mb-4 text-sm text-gray-400">{gettext("Select one or multiple options")}</p>
|
||||
<% else %>
|
||||
<p class="text-gray-400 text-sm mb-4">{gettext("Select one option")}</p>
|
||||
<p class="mb-4 text-sm text-gray-400">{gettext("Select one option")}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]">
|
||||
<div class="flex max-h-[500px] flex-col gap-2 overflow-y-auto">
|
||||
<%= if (length @poll.poll_opts) > 0 do %>
|
||||
<%= for {opt, idx} <- Enum.with_index(@poll.poll_opts) do %>
|
||||
<%= if (length @current_poll_vote) > 0 do %>
|
||||
<button class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white">
|
||||
<% voted = Enum.any?(@current_poll_vote, &(&1.poll_opt_id == opt.id)) %>
|
||||
<div class={[
|
||||
"relative flex shrink-0 items-center justify-between overflow-hidden rounded-xl border bg-gray-800 px-3 py-2 text-sm font-semibold text-white",
|
||||
voted && "border-primary-400",
|
||||
!voted && "border-gray-700"
|
||||
]}>
|
||||
<div
|
||||
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
|
||||
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
class={[
|
||||
"absolute inset-y-0 left-0 rounded-lg bg-primary-900/40 transition-all duration-700",
|
||||
voted && "bg-primary-700/60"
|
||||
]}
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<%= if (length Enum.filter(@current_poll_vote, fn(vote) -> vote.poll_opt_id == opt.id end)) > 0 do %>
|
||||
<%= if @poll.multiple do %>
|
||||
<span class="h-5 w-5 mt-0.5 point-select bg-white"></span>
|
||||
<% else %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-full point-select bg-white"></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if @poll.multiple do %>
|
||||
<span class="h-5 w-5 mt-0.5 point-select border-2 border-white"></span>
|
||||
<% else %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-full point-select border-2 border-white">
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
<div class="z-10 flex min-w-0 items-center gap-3 text-left">
|
||||
<span class={[
|
||||
"grid h-4 w-4 shrink-0 place-items-center border-2",
|
||||
@poll.multiple && "rounded",
|
||||
!@poll.multiple && "rounded-full",
|
||||
voted && "border-primary-300",
|
||||
!voted && "border-gray-500"
|
||||
]}>
|
||||
<span
|
||||
:if={voted}
|
||||
class={[
|
||||
"h-1.5 w-1.5 bg-primary-300",
|
||||
@poll.multiple && "rounded-sm",
|
||||
!@poll.multiple && "rounded-full"
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
<span :if={@show_results} class="text-sm z-10">
|
||||
<span :if={@show_results} class="z-10 shrink-0 text-xs font-bold text-white">
|
||||
{opt.percentage}% ({opt.vote_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
id={"poll-opt-#{idx}"}
|
||||
phx-click="select-poll-opt"
|
||||
phx-value-opt={idx}
|
||||
class="bg-gray-500 px-3 py-2 flex justify-between items-center rounded-lg relative text-white"
|
||||
aria-pressed={to_string(Enum.member?(@selected_poll_opt, "#{idx}"))}
|
||||
class={[
|
||||
"relative flex shrink-0 items-center justify-between overflow-hidden rounded-xl border px-3 py-2 text-sm font-semibold text-white transition-colors",
|
||||
Enum.member?(@selected_poll_opt, "#{idx}") &&
|
||||
"border-primary-400 bg-primary-900/40",
|
||||
!Enum.member?(@selected_poll_opt, "#{idx}") &&
|
||||
"border-gray-700 bg-gray-800 hover:border-primary-400"
|
||||
]}
|
||||
>
|
||||
<div
|
||||
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
|
||||
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
class="absolute inset-y-0 left-0 rounded-lg bg-primary-900/40 transition-all duration-700"
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<%= if Enum.member?(@selected_poll_opt, "#{idx}") do %>
|
||||
<%= if @poll.multiple do %>
|
||||
<span class="h-5 w-5 mt-0.5 point-select bg-white"></span>
|
||||
<% else %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-full point-select bg-white"></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if @poll.multiple do %>
|
||||
<span class="h-5 w-5 mt-0.5 point-select border-2 border-white"></span>
|
||||
<% else %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-full point-select border-2 border-white">
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
<div class="z-10 flex min-w-0 items-center gap-3 text-left">
|
||||
<span class={[
|
||||
"grid h-4 w-4 shrink-0 place-items-center border-2",
|
||||
@poll.multiple && "rounded",
|
||||
!@poll.multiple && "rounded-full",
|
||||
Enum.member?(@selected_poll_opt, "#{idx}") && "border-primary-300",
|
||||
!Enum.member?(@selected_poll_opt, "#{idx}") && "border-gray-500"
|
||||
]}>
|
||||
<span
|
||||
:if={Enum.member?(@selected_poll_opt, "#{idx}")}
|
||||
class={[
|
||||
"h-1.5 w-1.5 bg-primary-300",
|
||||
@poll.multiple && "rounded-sm",
|
||||
!@poll.multiple && "rounded-full"
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
<span :if={@show_results} class="text-sm z-10">
|
||||
<span :if={@show_results} class="z-10 shrink-0 text-xs font-bold text-white">
|
||||
{opt.percentage}% ({opt.vote_count})
|
||||
</span>
|
||||
</button>
|
||||
@@ -122,18 +166,33 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if (length @selected_poll_opt) == 0 || (length @current_poll_vote) > 0 do %>
|
||||
<button class="px-3 py-2 text-white font-medium bg-gray-500 rounded-md mt-3 mb-4 cursor-default">
|
||||
{gettext("Vote")}
|
||||
<%= if (length @current_poll_vote) > 0 do %>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
data-submitted
|
||||
class="mt-4 w-full cursor-not-allowed rounded-lg bg-gray-700 px-3 py-2 text-sm font-bold text-gray-400"
|
||||
>
|
||||
{gettext("Submitted")}
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="vote"
|
||||
phx-disable-with="..."
|
||||
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
|
||||
>
|
||||
{gettext("Vote")}
|
||||
</button>
|
||||
<%= if (length @selected_poll_opt) == 0 do %>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
class="mt-4 w-full cursor-not-allowed rounded-lg bg-gray-700 px-3 py-2 text-sm font-bold text-gray-400"
|
||||
>
|
||||
{gettext("Vote")}
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="vote"
|
||||
phx-disable-with="..."
|
||||
class="btn-gradient mt-4 w-full rounded-lg px-3 py-2 text-sm font-bold transition-colors"
|
||||
>
|
||||
{gettext("Vote")}
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,276 +1,274 @@
|
||||
defmodule ClaperWeb.EventLive.PostComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
own_message =
|
||||
assigns.post.attendee_identifier == assigns.attendee_identifier ||
|
||||
(not is_nil(assigns.current_user) && assigns.post.user_id == assigns.current_user.id)
|
||||
|
||||
host_message = leader?(assigns.post, assigns.event, assigns.leaders)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:own_message, own_message)
|
||||
|> assign(:host_message, host_message)
|
||||
|> assign(:show_actions, own_message || assigns.is_leader)
|
||||
|> assign(:can_react, assigns.reaction_enabled && !own_message)
|
||||
|> assign(:author_name, author_name(assigns.post))
|
||||
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<%= if @post.attendee_identifier == @attendee_identifier || (not is_nil(@current_user) && @post.user_id == @current_user.id) do %>
|
||||
<div class="px-4 pt-3 pb-8 rounded-b-lg rounded-tl-lg bg-gray-700 text-white relative z-0 break-word">
|
||||
<button
|
||||
phx-click={
|
||||
JS.toggle(
|
||||
to: "#post-menu-#{@post.id}",
|
||||
out: "animate__animated animate__fadeOut",
|
||||
in: "animate__animated animate__fadeIn"
|
||||
)
|
||||
}
|
||||
phx-click-away={
|
||||
JS.hide(to: "#post-menu-#{@post.id}", transition: "animate__animated animate__fadeOut")
|
||||
}
|
||||
class="float-right mr-1"
|
||||
>
|
||||
<img src="/images/icons/ellipsis-horizontal-white.svg" class="h-5" />
|
||||
</button>
|
||||
<article
|
||||
id={@id}
|
||||
class={[
|
||||
"relative rounded-xl border px-3 py-2 shadow-sm",
|
||||
@host_message &&
|
||||
"border-supporting-yellow-300 bg-supporting-yellow-50 text-supporting-yellow-950",
|
||||
!@host_message && @own_message && "border-gray-600 bg-gray-700 text-white",
|
||||
!@host_message && !@own_message && "border-gray-200 bg-white text-gray-900"
|
||||
]}
|
||||
>
|
||||
<header class={[
|
||||
"mb-1 flex min-h-6 items-center gap-2",
|
||||
@can_react && @show_actions && "pr-20",
|
||||
@can_react && !@show_actions && "pr-9",
|
||||
!@can_react && @show_actions && "pr-9"
|
||||
]}>
|
||||
<span class={[
|
||||
"truncate text-xs font-bold",
|
||||
@host_message && "text-supporting-yellow-900",
|
||||
!@host_message && @own_message && "text-gray-200",
|
||||
!@host_message && !@own_message && "text-gray-600"
|
||||
]}>
|
||||
{@author_name}
|
||||
</span>
|
||||
<span
|
||||
:if={@host_message}
|
||||
class="inline-flex items-center gap-1 rounded-full bg-supporting-yellow-200 px-2 py-1 text-[10px] font-bold uppercase text-supporting-yellow-900"
|
||||
>
|
||||
★ {gettext("Host")}
|
||||
</span>
|
||||
<span
|
||||
:if={pinned?(@post)}
|
||||
class="inline-flex items-center rounded-full bg-primary-100 px-2 py-1 text-[10px] font-bold uppercase text-primary-800"
|
||||
>
|
||||
{gettext("Pinned")}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<%= if @post.name || leader?(@post, @event, @leaders) || pinned?(@post) do %>
|
||||
<div class="inline-flex items-center">
|
||||
<%= if @post.name do %>
|
||||
<p class="text-white text-xs font-semibold mb-2 mr-2">{@post.name}</p>
|
||||
<% end %>
|
||||
<%= if leader?(@post, @event, @leaders) do %>
|
||||
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
|
||||
<img src="/images/icons/star.svg" class="h-3" />
|
||||
<span>{gettext("Host")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<button
|
||||
:if={@show_actions}
|
||||
type="button"
|
||||
aria-label={gettext("Message actions")}
|
||||
phx-click={
|
||||
JS.toggle(
|
||||
to: "#post-menu-#{@post.id}",
|
||||
out: "animate__animated animate__fadeOut",
|
||||
in: "animate__animated animate__fadeIn"
|
||||
)
|
||||
}
|
||||
phx-click-away={
|
||||
JS.hide(to: "#post-menu-#{@post.id}", transition: "animate__animated animate__fadeOut")
|
||||
}
|
||||
class={[
|
||||
"absolute right-2 top-2 grid h-11 w-11 place-items-center rounded-full text-xl leading-none",
|
||||
@own_message && !@host_message && "text-white hover:bg-white/10",
|
||||
(!@own_message || @host_message) && "text-gray-600 hover:bg-black/5"
|
||||
]}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
|
||||
<%= if pinned?(@post) do %>
|
||||
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2 ml-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-pin-filled"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path
|
||||
d="M15.113 3.21l.094 .083l5.5 5.5a1 1 0 0 1 -1.175 1.59l-3.172 3.171l-1.424 3.797a1 1 0 0 1 -.158 .277l-.07 .08l-1.5 1.5a1 1 0 0 1 -1.32 .082l-.095 -.083l-2.793 -2.792l-3.793 3.792a1 1 0 0 1 -1.497 -1.32l.083 -.094l3.792 -3.793l-2.792 -2.793a1 1 0 0 1 -.083 -1.32l.083 -.094l1.5 -1.5a1 1 0 0 1 .258 -.187l.098 -.042l3.796 -1.425l3.171 -3.17a1 1 0 0 1 1.497 -1.26z"
|
||||
stroke-width="0"
|
||||
fill="currentColor"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
<span>{gettext("Pinned")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<button
|
||||
:if={@can_react}
|
||||
type="button"
|
||||
data-message-reaction-trigger
|
||||
aria-label={gettext("React to message")}
|
||||
aria-haspopup="menu"
|
||||
phx-click={JS.toggle(to: "#reaction-menu-#{@post.id}", display: "flex")}
|
||||
class={[
|
||||
"absolute top-2 grid h-11 w-11 place-items-center rounded-full text-sm font-bold",
|
||||
@show_actions && "right-12",
|
||||
!@show_actions && "right-2",
|
||||
@own_message && !@host_message && "text-white hover:bg-white/10",
|
||||
(!@own_message || @host_message) && "text-gray-600 hover:bg-black/5"
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 20l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.96 6.053" />
|
||||
<path d="M16 19h6" />
|
||||
<path d="M19 16v6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={"post-menu-#{@post.id}"}
|
||||
class="hidden absolute right-4 top-7 bg-white rounded-lg px-5 py-2 animate__faster"
|
||||
>
|
||||
<span class="text-red-500">
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid,
|
||||
data: [confirm: gettext("Are you sure?")]
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p>{ClaperWeb.Helpers.format_body(@post.body)}</p>
|
||||
<div
|
||||
:if={@can_react}
|
||||
id={"reaction-menu-#{@post.id}"}
|
||||
data-message-reaction-menu
|
||||
role="menu"
|
||||
phx-click-away={JS.hide(to: "#reaction-menu-#{@post.id}")}
|
||||
class={[
|
||||
"absolute top-14 z-20 hidden items-center gap-1 rounded-full bg-gray-950 p-1.5 text-white shadow-2xl",
|
||||
@show_actions && "right-11",
|
||||
!@show_actions && "right-2"
|
||||
]}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Thumbs up")}
|
||||
phx-click={picker_reaction_click(Enum.member?(@liked_posts, @post.id), @post.id)}
|
||||
phx-value-type="👍"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class={picker_reaction_classes(Enum.member?(@liked_posts, @post.id))}
|
||||
>
|
||||
👍
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Heart")}
|
||||
phx-click={picker_reaction_click(Enum.member?(@loved_posts, @post.id), @post.id)}
|
||||
phx-value-type="❤️"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class={picker_reaction_classes(Enum.member?(@loved_posts, @post.id))}
|
||||
>
|
||||
❤️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Laugh")}
|
||||
phx-click={picker_reaction_click(Enum.member?(@loled_posts, @post.id), @post.id)}
|
||||
phx-value-type="😂"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class={picker_reaction_classes(Enum.member?(@loled_posts, @post.id))}
|
||||
>
|
||||
😂
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex h-6 text-sm float-right text-white space-x-2">
|
||||
<%= if @post.like_count > 0 do %>
|
||||
<div class="flex px-1 items-center">
|
||||
<img src="/images/icons/thumb.svg" class="h-4" />
|
||||
<span class="ml-1 text-white">{@post.like_count}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @post.love_count > 0 do %>
|
||||
<div class="flex px-1 items-center">
|
||||
<img src="/images/icons/heart.svg" class="h-4" />
|
||||
<span class="ml-1 text-white">{@post.love_count}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @post.lol_count > 0 do %>
|
||||
<div class="flex px-1 items-center">
|
||||
<img src="/images/icons/laugh.svg" class="h-4" />
|
||||
<span class="ml-1 text-white">{@post.lol_count}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-4 pt-3 pb-8 rounded-b-lg rounded-tr-lg bg-white text-black relative z-0 break-all">
|
||||
<%= if @post.name || leader?(@post, @event, @leaders) do %>
|
||||
<div class="inline-flex items-center">
|
||||
<%= if @post.name do %>
|
||||
<p class="text-black text-xs font-semibold mb-2 mr-2">{@post.name}</p>
|
||||
<% end %>
|
||||
<%= if leader?(@post, @event, @leaders) do %>
|
||||
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
|
||||
<img src="/images/icons/star.svg" class="h-3" />
|
||||
<span>{gettext("Host")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div
|
||||
id={"post-menu-#{@post.id}"}
|
||||
class="absolute right-3 top-12 z-20 hidden rounded-xl bg-gray-950 px-4 py-3 text-sm shadow-2xl animate__faster"
|
||||
>
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
class: "font-semibold text-supporting-red-400",
|
||||
phx_click: "delete",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid,
|
||||
data: [confirm: gettext("Are you sure?")]
|
||||
)}
|
||||
</div>
|
||||
|
||||
<%= if @is_leader do %>
|
||||
<button
|
||||
phx-click={
|
||||
JS.toggle(
|
||||
to: "#post-menu-#{@post.id}",
|
||||
out: "animate__animated animate__fadeOut",
|
||||
in: "animate__animated animate__fadeIn"
|
||||
)
|
||||
}
|
||||
phx-click-away={
|
||||
JS.hide(
|
||||
to: "#post-menu-#{@post.id}",
|
||||
transition: "animate__animated animate__fadeOut"
|
||||
)
|
||||
}
|
||||
class="float-right mr-1"
|
||||
>
|
||||
<img src="/images/icons/ellipsis-horizontal.svg" class="h-5" />
|
||||
</button>
|
||||
<div
|
||||
id={"post-menu-#{@post.id}"}
|
||||
class="hidden absolute right-4 top-7 bg-gray-900 rounded-lg px-5 py-2"
|
||||
>
|
||||
<span class="text-red-500">
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid,
|
||||
data: [confirm: gettext("Are you sure?")]
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="break-words text-sm leading-5">{ClaperWeb.Helpers.format_body(@post.body)}</p>
|
||||
|
||||
<%= if pinned?(@post) do %>
|
||||
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2 ml-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-pin-filled"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path
|
||||
d="M15.113 3.21l.094 .083l5.5 5.5a1 1 0 0 1 -1.175 1.59l-3.172 3.171l-1.424 3.797a1 1 0 0 1 -.158 .277l-.07 .08l-1.5 1.5a1 1 0 0 1 -1.32 .082l-.095 -.083l-2.793 -2.792l-3.793 3.792a1 1 0 0 1 -1.497 -1.32l.083 -.094l3.792 -3.793l-2.792 -2.793a1 1 0 0 1 -.083 -1.32l.083 -.094l1.5 -1.5a1 1 0 0 1 .258 -.187l.098 -.042l3.796 -1.425l3.171 -3.17a1 1 0 0 1 1.497 -1.26z"
|
||||
stroke-width="0"
|
||||
fill="currentColor"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
<span>{gettext("Pinned")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p>{ClaperWeb.Helpers.format_body(@post.body)}</p>
|
||||
|
||||
<div class="flex h-6 text-xs float-right space-x-2">
|
||||
<%= if @reaction_enabled do %>
|
||||
<%= if not Enum.member?(@liked_posts, @post.id) do %>
|
||||
<button
|
||||
phx-click="react"
|
||||
phx-value-type="👍"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class="flex rounded-full px-3 py-1 border border-gray-300 bg-white items-center"
|
||||
>
|
||||
<img src="/images/icons/thumb.svg" class="h-4" />
|
||||
<%= if @post.like_count > 0 do %>
|
||||
<span class="ml-1">{@post.like_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="unreact"
|
||||
phx-value-type="👍"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class="flex rounded-full px-3 py-1 border border-gray-300 bg-gray-100 items-center"
|
||||
>
|
||||
<span class="">
|
||||
<img src="/images/icons/thumb.svg" class="h-4" />
|
||||
</span>
|
||||
<%= if @post.like_count > 0 do %>
|
||||
<span class="ml-1">{@post.like_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if not Enum.member?(@loved_posts, @post.id) do %>
|
||||
<button
|
||||
phx-click="react"
|
||||
phx-value-type="❤️"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class="flex rounded-full px-3 py-1 border border-gray-300 bg-white items-center"
|
||||
>
|
||||
<img src="/images/icons/heart.svg" class="h-4" />
|
||||
<%= if @post.love_count > 0 do %>
|
||||
<span class="ml-1">{@post.love_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="unreact"
|
||||
phx-value-type="❤️"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class="flex rounded-full px-3 py-1 border border-gray-300 bg-gray-100 items-center"
|
||||
>
|
||||
<img src="/images/icons/heart.svg" class="h-4" />
|
||||
<%= if @post.love_count > 0 do %>
|
||||
<span class="ml-1">{@post.love_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if not Enum.member?(@loled_posts, @post.id) do %>
|
||||
<button
|
||||
phx-click="react"
|
||||
phx-value-type="😂"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class="flex rounded-full px-3 py-1 border border-gray-300 bg-white items-center"
|
||||
>
|
||||
<img src="/images/icons/laugh.svg" class="h-4" />
|
||||
<%= if @post.lol_count > 0 do %>
|
||||
<span class="ml-1">{@post.lol_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="unreact"
|
||||
phx-value-type="😂"
|
||||
phx-value-post-id={@post.uuid}
|
||||
class="flex rounded-full px-3 py-1 border border-gray-300 bg-gray-100 items-center"
|
||||
>
|
||||
<img src="/images/icons/laugh.svg" class="h-4" />
|
||||
<%= if @post.lol_count > 0 do %>
|
||||
<span class="ml-1">{@post.lol_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div
|
||||
:if={
|
||||
@reaction_enabled &&
|
||||
(@post.like_count > 0 || @post.love_count > 0 || @post.lol_count > 0)
|
||||
}
|
||||
class="mt-1.5 flex flex-wrap justify-end gap-1"
|
||||
>
|
||||
<button
|
||||
:if={@post.like_count > 0}
|
||||
type="button"
|
||||
data-reaction-chip
|
||||
disabled={@own_message}
|
||||
phx-click={if Enum.member?(@liked_posts, @post.id), do: "unreact", else: "react"}
|
||||
phx-value-type="👍"
|
||||
phx-value-post-id={@post.uuid}
|
||||
aria-pressed={to_string(Enum.member?(@liked_posts, @post.id))}
|
||||
class={
|
||||
reaction_chip_classes(
|
||||
Enum.member?(@liked_posts, @post.id),
|
||||
@own_message && !@host_message
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>👍</span><span :if={@post.like_count > 0}>{@post.like_count}</span>
|
||||
</button>
|
||||
<button
|
||||
:if={@post.love_count > 0}
|
||||
data-reaction-chip
|
||||
type="button"
|
||||
disabled={@own_message}
|
||||
phx-click={if Enum.member?(@loved_posts, @post.id), do: "unreact", else: "react"}
|
||||
phx-value-type="❤️"
|
||||
phx-value-post-id={@post.uuid}
|
||||
aria-pressed={to_string(Enum.member?(@loved_posts, @post.id))}
|
||||
class={
|
||||
reaction_chip_classes(
|
||||
Enum.member?(@loved_posts, @post.id),
|
||||
@own_message && !@host_message
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>❤️</span><span :if={@post.love_count > 0}>{@post.love_count}</span>
|
||||
</button>
|
||||
<button
|
||||
:if={@post.lol_count > 0}
|
||||
data-reaction-chip
|
||||
type="button"
|
||||
disabled={@own_message}
|
||||
phx-click={if Enum.member?(@loled_posts, @post.id), do: "unreact", else: "react"}
|
||||
phx-value-type="😂"
|
||||
phx-value-post-id={@post.uuid}
|
||||
aria-pressed={to_string(Enum.member?(@loled_posts, @post.id))}
|
||||
class={
|
||||
reaction_chip_classes(
|
||||
Enum.member?(@loled_posts, @post.id),
|
||||
@own_message && !@host_message
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>😂</span><span :if={@post.lol_count > 0}>{@post.lol_count}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
"""
|
||||
end
|
||||
|
||||
defp author_name(%{name: name}) when is_binary(name) and name != "", do: name
|
||||
defp author_name(_post), do: gettext("Anonymous")
|
||||
|
||||
defp reaction_chip_classes(selected, dark_message) do
|
||||
[
|
||||
"inline-flex h-7 min-w-7 items-center justify-center gap-1 rounded-full border px-2 text-[11px] font-semibold transition-colors",
|
||||
selected && "border-primary-400 bg-primary-100 text-primary-900",
|
||||
!selected && dark_message && "border-white/30 bg-transparent text-white hover:bg-white/10",
|
||||
!selected && !dark_message && "border-gray-300 bg-white text-gray-800 hover:bg-gray-100"
|
||||
]
|
||||
end
|
||||
|
||||
defp picker_reaction_classes(selected) do
|
||||
[
|
||||
"grid h-11 w-11 place-items-center rounded-full text-lg transition-colors hover:bg-white/10",
|
||||
selected && "bg-primary-500"
|
||||
]
|
||||
end
|
||||
|
||||
defp picker_reaction_click(selected, post_id) do
|
||||
JS.push(if(selected, do: "unreact", else: "react"))
|
||||
|> JS.hide(to: "#reaction-menu-#{post_id}")
|
||||
end
|
||||
|
||||
defp leader?(post, event, leaders) do
|
||||
!is_nil(post.user_id) &&
|
||||
(post.user_id == event.user_id ||
|
||||
Enum.any?(leaders, fn leader ->
|
||||
leader.user_id == post.user_id
|
||||
end))
|
||||
Enum.any?(leaders, fn leader -> leader.user_id == post.user_id end))
|
||||
end
|
||||
|
||||
defp pinned?(post), do: post.pinned
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
module={ClaperWeb.EventLive.EmbedIframeComponent}
|
||||
provider={@current_embed.provider}
|
||||
content={@current_embed.content}
|
||||
title={@current_embed.title}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign_new(:focus_mode, fn -> false end)
|
||||
|> assign(:is_submitted, length(assigns.current_quiz_responses) > 0)
|
||||
|> assign(
|
||||
:current_question,
|
||||
@@ -14,40 +15,64 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
:has_selection,
|
||||
length(assigns.selected_quiz_question_opts) > 0
|
||||
)
|
||||
|> assign(
|
||||
:response_opt_ids,
|
||||
Enum.map(assigns.current_quiz_responses, & &1.quiz_question_opt_id)
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<div class="font-display">
|
||||
<div
|
||||
:if={!@focus_mode}
|
||||
id="collapsed-quiz"
|
||||
class="bg-gray-900 py-3 px-6 text-black shadow-lg mx-auto rounded-full w-max hidden"
|
||||
class="mx-auto hidden w-max rounded-full bg-gray-900 px-5 py-3 shadow-xl ring-1 ring-white/10"
|
||||
>
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_quiz()} phx-target={@myself}>
|
||||
<div class="text-white flex space-x-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="block h-full w-full cursor-pointer"
|
||||
phx-click={toggle_quiz()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-6 w-6"
|
||||
class="h-5 w-5 text-primary-300"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-
|
||||
linejoin="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-bold">{gettext("See current quiz")}</span>
|
||||
<span class="text-sm font-bold">{gettext("See current quiz")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div id="extended-quiz" class="bg-gray-900 w-full p-4 text-black shadow-lg rounded-md">
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_quiz()} phx-target={@myself}>
|
||||
<div id="poll-pane" class="float-right mt-2">
|
||||
<div
|
||||
id="extended-quiz"
|
||||
class={[
|
||||
"w-full rounded-2xl bg-gray-900 p-4 text-gray-100",
|
||||
@focus_mode && "shadow-none ring-0",
|
||||
!@focus_mode && "shadow-2xl ring-1 ring-white/10"
|
||||
]}
|
||||
>
|
||||
<div class="relative pr-8">
|
||||
<button
|
||||
:if={!@focus_mode}
|
||||
id="quiz-pane"
|
||||
type="button"
|
||||
aria-label={gettext("Close")}
|
||||
class="absolute -right-1 -top-1 grid h-8 w-8 place-items-center rounded-full text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||
phx-click={toggle_quiz()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -55,75 +80,110 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<p class="text-sm text-gray-400 my-1">{gettext("Current quiz")}</p>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-400">{gettext("Current quiz")}</p>
|
||||
<%= if is_nil(@current_question) do %>
|
||||
<p class="text-white text-xl font-semibold mb-2">{@quiz.title}</p>
|
||||
<p class="mb-2 text-lg font-bold leading-snug text-white">{@quiz.title}</p>
|
||||
<% else %>
|
||||
<p class="text-white text-xl font-semibold mb-2">{@current_question.content}</p>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
<p class="mb-1 text-lg font-bold leading-snug text-white">
|
||||
{@current_question.content}
|
||||
</p>
|
||||
<p class="mb-4 text-sm text-gray-400">
|
||||
{@current_quiz_question_idx + 1}/{length(@quiz.quiz_questions)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]">
|
||||
<div class="flex max-h-[500px] flex-col gap-2 overflow-y-auto">
|
||||
<%= if @current_question do %>
|
||||
<%= for {opt, _idx} <- Enum.with_index(@current_question.quiz_question_opts) do %>
|
||||
<%= if @is_submitted do %>
|
||||
<div class={"bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white #{if opt.is_correct, do: "bg-green-600"} #{if not opt.is_correct && Enum.member?(Enum.map(@current_quiz_responses, &(&1.quiz_question_opt_id)), opt.id), do: "bg-red-600"}"}>
|
||||
<div class="flex justify-between items-center z-10 text-left w-full">
|
||||
<div class="flex items-center text-left space-x-3">
|
||||
<%= if Enum.member?(Enum.map(@current_quiz_responses, &(&1.quiz_question_opt_id)), opt.id) do %>
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select bg-white"></div>
|
||||
<% else %>
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
|
||||
<span class="text-sm">{opt.percentage}% ({opt.response_count})</span>
|
||||
<% selected = Enum.member?(@response_opt_ids, opt.id) %>
|
||||
<div class={[
|
||||
"relative flex items-center justify-between rounded-xl border px-3 py-2 text-sm font-semibold transition-colors",
|
||||
opt.is_correct &&
|
||||
"border-supporting-green-500 bg-supporting-green-900/40 text-supporting-green-200",
|
||||
!opt.is_correct && selected &&
|
||||
"border-supporting-red-400 bg-supporting-red-900/40 text-supporting-red-200",
|
||||
!opt.is_correct && !selected &&
|
||||
"border-gray-700 bg-gray-800 text-gray-300 opacity-60"
|
||||
]}>
|
||||
<div class="flex min-w-0 items-center gap-3 text-left">
|
||||
<span class={[
|
||||
"grid h-4 w-4 shrink-0 place-items-center rounded border-2",
|
||||
opt.is_correct && "border-supporting-green-500",
|
||||
!opt.is_correct && selected && "border-supporting-red-400",
|
||||
!opt.is_correct && !selected && "border-gray-500"
|
||||
]}>
|
||||
<span
|
||||
:if={selected}
|
||||
class={[
|
||||
"h-1.5 w-1.5 rounded-sm",
|
||||
opt.is_correct && "bg-supporting-green-500",
|
||||
!opt.is_correct && "bg-supporting-red-400"
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 text-xs font-bold">
|
||||
{opt.percentage}% ({opt.response_count})
|
||||
</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="select-quiz-question-opt"
|
||||
phx-value-opt={opt.id}
|
||||
class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white"
|
||||
aria-pressed={
|
||||
to_string(Enum.any?(@selected_quiz_question_opts, &(&1.id == opt.id)))
|
||||
}
|
||||
class={[
|
||||
"relative flex items-center justify-between rounded-xl border px-3 py-2 text-sm font-semibold text-white transition-colors",
|
||||
Enum.any?(@selected_quiz_question_opts, &(&1.id == opt.id)) &&
|
||||
"border-primary-400 bg-primary-900/40",
|
||||
!Enum.any?(@selected_quiz_question_opts, &(&1.id == opt.id)) &&
|
||||
"border-gray-700 bg-gray-800 hover:border-primary-400"
|
||||
]}
|
||||
>
|
||||
<div class="bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<%= if Enum.any?(@selected_quiz_question_opts, fn x -> x.id == opt.id end) do %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-md point-select bg-white"></span>
|
||||
<% else %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
<div class="flex min-w-0 items-center gap-3 text-left">
|
||||
<span class={[
|
||||
"grid h-4 w-4 shrink-0 place-items-center rounded border-2",
|
||||
Enum.any?(@selected_quiz_question_opts, &(&1.id == opt.id)) &&
|
||||
"border-primary-300",
|
||||
!Enum.any?(@selected_quiz_question_opts, &(&1.id == opt.id)) &&
|
||||
"border-gray-500"
|
||||
]}>
|
||||
<span
|
||||
:if={Enum.any?(@selected_quiz_question_opts, &(&1.id == opt.id))}
|
||||
class="h-1.5 w-1.5 rounded-sm bg-primary-300"
|
||||
>
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-gray-400 flex flex-col items-center justify-center font-semibold text-lg mt-4">
|
||||
<div class="mt-4 flex flex-col items-center justify-center text-center font-semibold text-white">
|
||||
<%= if @quiz.show_results do %>
|
||||
<p>{gettext("Your score")}</p>
|
||||
<p class="text-6xl font-bold mt-2">
|
||||
<p class="text-sm text-gray-400">{gettext("Your score")}</p>
|
||||
<p class="mt-2 text-5xl font-bold">
|
||||
{elem(@quiz_score, 0)}/{elem(@quiz_score, 1)}
|
||||
</p>
|
||||
<button
|
||||
phx-click="show-quiz-results"
|
||||
class="mt-7 px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
|
||||
class="btn-gradient mt-6 w-full rounded-lg px-3 py-2 text-sm font-bold"
|
||||
>
|
||||
{gettext("Show results")}
|
||||
</button>
|
||||
<% else %>
|
||||
<p>{gettext("Waiting for results...")}</p>
|
||||
<p class="text-sm text-gray-400">{gettext("Waiting for results...")}</p>
|
||||
<svg
|
||||
class="w-32 h-32 mt-4"
|
||||
class="mt-4 h-24 w-24 text-primary-300"
|
||||
viewBox="0 0 360 360"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -145,9 +205,12 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div :if={not @is_submitted} class="flex justify-between items-baseline w-full h-12 mt-5">
|
||||
<div :if={not @is_submitted} id="quiz-actions" class="mt-4 flex w-full items-center gap-2">
|
||||
<%= if @current_quiz_question_idx > 0 do %>
|
||||
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
|
||||
<button
|
||||
phx-click="prev-question"
|
||||
class="shrink-0 rounded-lg px-3 py-2 text-sm font-bold text-white hover:bg-white/10"
|
||||
>
|
||||
{gettext("Back")}
|
||||
</button>
|
||||
<% end %>
|
||||
@@ -155,29 +218,40 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
<%= if @current_quiz_question_idx < length(@quiz.quiz_questions) - 1 do %>
|
||||
<button
|
||||
phx-click="next-question"
|
||||
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
|
||||
class={[
|
||||
"flex-1 rounded-lg px-3 py-2 text-sm font-bold",
|
||||
@has_selection && "btn-gradient",
|
||||
!@has_selection && "cursor-not-allowed bg-gray-700 text-gray-400"
|
||||
]}
|
||||
disabled={not @has_selection}
|
||||
>
|
||||
{gettext("Next")}
|
||||
</button>
|
||||
<% else %>
|
||||
<%= if is_nil(@current_user) && !@quiz.allow_anonymous do %>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="text-white text-sm font-semibold">
|
||||
{gettext("Please sign in to submit your answers")}
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{link(
|
||||
gettext("Sign in"),
|
||||
target: "_blank",
|
||||
to: ~p"/users/log_in",
|
||||
class:
|
||||
"inline px-3 py-2 text-white font-medium rounded-md h-full bg-primary-400 hover:bg-primary-500"
|
||||
"btn-gradient inline w-full rounded-lg px-3 py-2 text-center text-sm font-bold"
|
||||
)}
|
||||
<p
|
||||
id="quiz-sign-in-prompt"
|
||||
class="text-center text-[10px] leading-tight text-gray-400"
|
||||
>
|
||||
{gettext("Please sign in to submit your answers")}
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="submit-quiz"
|
||||
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
|
||||
class={[
|
||||
"flex-1 rounded-lg px-3 py-2 text-sm font-bold",
|
||||
@has_selection && "btn-gradient",
|
||||
!@has_selection && "cursor-not-allowed bg-gray-700 text-gray-400"
|
||||
]}
|
||||
disabled={not @has_selection}
|
||||
>
|
||||
{gettext("Submit")}
|
||||
@@ -191,20 +265,22 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
@is_submitted && @quiz.show_results &&
|
||||
@current_quiz_question_idx <= length(@quiz.quiz_questions) - 1
|
||||
}
|
||||
class="flex justify-between items-baseline w-full h-12 mt-5"
|
||||
id="quiz-review-actions"
|
||||
class="mt-4 flex w-full items-center justify-between gap-3"
|
||||
>
|
||||
<%= if (@current_quiz_question_idx > 0 && @current_quiz_question_idx <= length(@quiz.quiz_questions) - 1) do %>
|
||||
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
|
||||
<button
|
||||
phx-click="prev-question"
|
||||
class="rounded-lg px-3 py-2 text-sm font-bold text-white hover:bg-white/10"
|
||||
>
|
||||
{gettext("Back")}
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-1/2"></div>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
:if={@current_quiz_question_idx <= length(@quiz.quiz_questions) - 1}
|
||||
phx-click="next-question"
|
||||
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md h-full"
|
||||
class="btn-gradient flex-1 rounded-lg px-3 py-2 text-sm font-bold"
|
||||
>
|
||||
{gettext("Next")}
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
alias Claper.Interactions
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
alias Claper.{Posts, Polls, Forms, Quizzes, Stats, Transcriptions}
|
||||
alias Claper.{Posts, Polls, Forms, Presentations, Quizzes, Stats, Transcriptions}
|
||||
alias ClaperWeb.Presence
|
||||
|
||||
on_mount(ClaperWeb.AttendeeLiveAuth)
|
||||
@@ -20,6 +20,8 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
lol: {:lol_count, :lol_posts}
|
||||
}
|
||||
|
||||
@post_reaction_types %{"👍" => :like, "❤️" => :love, "😂" => :lol}
|
||||
|
||||
@impl true
|
||||
def mount(%{"code" => code}, session, socket) do
|
||||
with %{"locale" => locale} <- session do
|
||||
@@ -86,9 +88,12 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|
||||
posts = list_posts(socket, event.uuid)
|
||||
|
||||
slide_urls = Presentations.get_slide_urls(event.presentation_file)
|
||||
current_position = event.presentation_file.presentation_state.position
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:attendees_nb, 1)
|
||||
|> assign(:attendees_nb, online_attendees(event))
|
||||
|> assign(:post_changeset, post_changeset)
|
||||
|> assign(:like_posts, reacted_posts(socket, event.id, "👍"))
|
||||
|> assign(:love_posts, reacted_posts(socket, event.id, "❤️"))
|
||||
@@ -98,6 +103,8 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|> assign(:current_quiz_question_idx, 0)
|
||||
|> assign(:event, event)
|
||||
|> assign(:state, event.presentation_file.presentation_state)
|
||||
|> assign(:slide_urls, slide_urls)
|
||||
|> assign(:current_slide_url, Enum.at(slide_urls, current_position))
|
||||
|> assign(:transcription_text, "")
|
||||
|> assign(
|
||||
:transcription_config,
|
||||
@@ -107,7 +114,7 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|> stream(:posts, posts)
|
||||
|> assign(:post_count, Enum.count(posts))
|
||||
|> starting_soon_assigns(event)
|
||||
|> get_current_interaction(event, event.presentation_file.presentation_state.position)
|
||||
|> get_current_interaction(event, current_position)
|
||||
|> check_leader(event)
|
||||
|> leader_list(event)
|
||||
|
||||
@@ -124,6 +131,13 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
end
|
||||
end
|
||||
|
||||
defp online_attendees(event) do
|
||||
event.uuid
|
||||
|> then(&Presence.list("event:#{&1}"))
|
||||
|> Enum.count()
|
||||
|> max(1)
|
||||
end
|
||||
|
||||
defp check_leader(%{assigns: %{current_user: current_user} = _assigns} = socket, event)
|
||||
when is_map(current_user) do
|
||||
is_leader =
|
||||
@@ -198,10 +212,25 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|
||||
@impl true
|
||||
def handle_info({:state_updated, presentation_state}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:state, presentation_state)
|
||||
|> stream(:posts, list_posts(socket, socket.assigns.event.uuid), reset: true)}
|
||||
position_changed = socket.assigns.state.position != presentation_state.position
|
||||
|
||||
message_reactions_changed =
|
||||
socket.assigns.state.message_reaction_enabled !=
|
||||
presentation_state.message_reaction_enabled
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:state, presentation_state)
|
||||
|> assign_current_slide(presentation_state.position)
|
||||
|
||||
socket =
|
||||
if message_reactions_changed do
|
||||
stream(socket, :posts, list_posts(socket, socket.assigns.event.uuid), reset: true)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply, if(position_changed, do: refresh_current_interaction(socket), else: socket)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -248,16 +277,18 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_page, page)
|
||||
|> assign(:state, %{socket.assigns.state | position: page})
|
||||
|> assign_current_slide(page)
|
||||
|> get_current_interaction(socket.assigns.event, page)
|
||||
|> push_event("reset-global-react", %{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:current_interaction, interaction},
|
||||
{:current_interaction, _interaction},
|
||||
socket
|
||||
) do
|
||||
{:noreply, socket |> load_current_interaction(interaction, false)}
|
||||
{:noreply, refresh_current_interaction(socket)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -294,59 +325,43 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:poll_updated, %Claper.Polls.Poll{enabled: true} = poll}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(poll, true)}
|
||||
def handle_info({:poll_updated, %Claper.Polls.Poll{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:poll_deleted, %Claper.Polls.Poll{enabled: true}}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_interaction, fn _current_interaction -> nil end)}
|
||||
def handle_info({:poll_deleted, %Claper.Polls.Poll{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:form_updated, %Claper.Forms.Form{enabled: true} = form}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(form, true)}
|
||||
def handle_info({:form_updated, %Claper.Forms.Form{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:form_deleted, %Claper.Forms.Form{enabled: true}}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_interaction, fn _current_interaction -> nil end)}
|
||||
def handle_info({:form_deleted, %Claper.Forms.Form{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:embed_updated, %Claper.Embeds.Embed{enabled: true} = embed}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(embed, true)}
|
||||
def handle_info({:embed_updated, %Claper.Embeds.Embed{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:embed_deleted, %Claper.Embeds.Embed{enabled: true}}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_interaction, fn _current_interaction -> nil end)}
|
||||
def handle_info({:embed_deleted, %Claper.Embeds.Embed{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_updated, %Claper.Quizzes.Quiz{enabled: true} = quiz}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(quiz, true)}
|
||||
def handle_info({:quiz_updated, %Claper.Quizzes.Quiz{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_deleted, %Claper.Quizzes.Quiz{enabled: true}}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_interaction, fn _current_interaction -> nil end)}
|
||||
def handle_info({:quiz_deleted, %Claper.Quizzes.Quiz{}}, socket) do
|
||||
{:noreply, refresh_current_interaction(socket, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -387,11 +402,39 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"event-id" => event_id, "id" => id}, socket) do
|
||||
post = Posts.get_post!(id, [:event])
|
||||
{:ok, _} = Posts.delete_post(post)
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
post = Posts.get_post_for_event(id, socket.assigns.event.id, [:event])
|
||||
|
||||
{:noreply, assign(socket, :posts, list_posts(socket, event_id))}
|
||||
if post && can_delete_post?(socket, post) do
|
||||
{:ok, _} = Posts.delete_post(post)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", _params, %{assigns: %{state: %{chat_enabled: false}}} = socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
event,
|
||||
_params,
|
||||
%{assigns: %{state: %{message_reaction_enabled: false}}} = socket
|
||||
)
|
||||
when event in ["react", "unreact"] do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"save",
|
||||
_params,
|
||||
%{assigns: %{state: %{anonymous_chat_enabled: false}, nickname: nickname}} = socket
|
||||
)
|
||||
when nickname in [nil, ""] do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -409,7 +452,10 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|
||||
case Posts.create_post(socket.assigns.event, post_params) do
|
||||
{:ok, _post} ->
|
||||
{:noreply, socket}
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:post_changeset, Posts.Post.changeset(%Posts.Post{}, %{}))
|
||||
|> push_event("post-saved", %{})}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, post_changeset: changeset)}
|
||||
@@ -430,7 +476,10 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|
||||
case Posts.create_post(socket.assigns.event, post_params) do
|
||||
{:ok, _post} ->
|
||||
{:noreply, socket}
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:post_changeset, Posts.Post.changeset(%Posts.Post{}, %{}))
|
||||
|> push_event("post-saved", %{})}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, post_changeset: changeset)}
|
||||
@@ -460,11 +509,14 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
%{"type" => type},
|
||||
socket
|
||||
) do
|
||||
case Map.get(@global_react_types, type) do
|
||||
nil ->
|
||||
case {socket.assigns.state.message_reaction_enabled, Map.get(@global_react_types, type)} do
|
||||
{false, _} ->
|
||||
{:noreply, socket}
|
||||
|
||||
type_atom ->
|
||||
{_, nil} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{true, type_atom} ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{socket.assigns.event.uuid}",
|
||||
@@ -477,9 +529,24 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|
||||
@impl true
|
||||
def handle_event("set-nickname", %{"nickname" => nickname}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:nickname, nickname)}
|
||||
nickname = String.trim(nickname)
|
||||
|
||||
cond do
|
||||
nickname == "" && socket.assigns.state.anonymous_chat_enabled ->
|
||||
{:noreply, assign(socket, :nickname, "")}
|
||||
|
||||
nickname == "" ->
|
||||
{:noreply, socket}
|
||||
|
||||
true ->
|
||||
changeset = Posts.Post.nickname_changeset(%Posts.Post{}, %{"name" => nickname})
|
||||
|
||||
if changeset.valid? do
|
||||
{:noreply, assign(socket, :nickname, nickname)}
|
||||
else
|
||||
{:noreply, assign(socket, :post_changeset, %{changeset | action: :insert})}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -489,15 +556,13 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
%{assigns: %{current_user: current_user} = _assigns} = socket
|
||||
)
|
||||
when is_map(current_user) do
|
||||
case type do
|
||||
"👍" ->
|
||||
{:noreply, add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :like)}
|
||||
case Map.get(@post_reaction_types, type) do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
||||
"❤️" ->
|
||||
{:noreply, add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :love)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply, add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :lol)}
|
||||
reaction ->
|
||||
{:noreply,
|
||||
add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, reaction)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -507,32 +572,17 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
%{"type" => type, "post-id" => post_id} = _params,
|
||||
%{assigns: %{attendee_identifier: attendee_identifier} = _assigns} = socket
|
||||
) do
|
||||
case type do
|
||||
"👍" ->
|
||||
{:noreply,
|
||||
add_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:like
|
||||
)}
|
||||
case Map.get(@post_reaction_types, type) do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
||||
"❤️" ->
|
||||
reaction ->
|
||||
{:noreply,
|
||||
add_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:love
|
||||
)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply,
|
||||
add_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:lol
|
||||
reaction
|
||||
)}
|
||||
end
|
||||
end
|
||||
@@ -544,18 +594,13 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
%{assigns: %{current_user: current_user} = _assigns} = socket
|
||||
)
|
||||
when is_map(current_user) do
|
||||
case type do
|
||||
"👍" ->
|
||||
{:noreply,
|
||||
remove_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :like)}
|
||||
case Map.get(@post_reaction_types, type) do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
||||
"❤️" ->
|
||||
reaction ->
|
||||
{:noreply,
|
||||
remove_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :love)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply,
|
||||
remove_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :lol)}
|
||||
remove_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, reaction)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -565,32 +610,17 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
%{"type" => type, "post-id" => post_id} = _params,
|
||||
%{assigns: %{attendee_identifier: attendee_identifier} = _assigns} = socket
|
||||
) do
|
||||
case type do
|
||||
"👍" ->
|
||||
{:noreply,
|
||||
remove_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:like
|
||||
)}
|
||||
case Map.get(@post_reaction_types, type) do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
||||
"❤️" ->
|
||||
reaction ->
|
||||
{:noreply,
|
||||
remove_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:love
|
||||
)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply,
|
||||
remove_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:lol
|
||||
reaction
|
||||
)}
|
||||
end
|
||||
end
|
||||
@@ -800,25 +830,56 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
end
|
||||
|
||||
defp add_reaction(socket, post_id, params, type) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
with %Posts.Post{} = post <-
|
||||
Posts.get_post_for_event(post_id, socket.assigns.event.id, [:event]),
|
||||
false <- own_post?(socket, post),
|
||||
{:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do
|
||||
{count_field, posts_field} = @reaction_fields[type]
|
||||
|
||||
{:ok, _} = Posts.update_post(post, %{count_field => Map.get(post, count_field) + 1})
|
||||
update(socket, posts_field, fn posts -> [post.id | posts] end)
|
||||
else
|
||||
_ -> socket
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_reaction(socket, post_id, params, type) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
with %Posts.Post{} = post <-
|
||||
Posts.get_post_for_event(post_id, socket.assigns.event.id, [:event]),
|
||||
{:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do
|
||||
{count_field, posts_field} = @reaction_fields[type]
|
||||
|
||||
{:ok, _} = Posts.update_post(post, %{count_field => Map.get(post, count_field) - 1})
|
||||
update(socket, posts_field, fn posts -> List.delete(posts, post.id) end)
|
||||
else
|
||||
_ -> socket
|
||||
end
|
||||
end
|
||||
|
||||
defp can_delete_post?(%{assigns: %{is_leader: true}}, _post), do: true
|
||||
|
||||
defp can_delete_post?(%{assigns: %{current_user: %{id: user_id}}}, %{user_id: user_id}),
|
||||
do: true
|
||||
|
||||
defp can_delete_post?(
|
||||
%{assigns: %{attendee_identifier: attendee_identifier}},
|
||||
%{attendee_identifier: attendee_identifier}
|
||||
),
|
||||
do: true
|
||||
|
||||
defp can_delete_post?(_socket, _post), do: false
|
||||
|
||||
defp own_post?(%{assigns: %{current_user: %{id: user_id}}}, %{user_id: user_id}), do: true
|
||||
|
||||
defp own_post?(
|
||||
%{assigns: %{attendee_identifier: attendee_identifier}},
|
||||
%{attendee_identifier: attendee_identifier}
|
||||
)
|
||||
when not is_nil(attendee_identifier),
|
||||
do: true
|
||||
|
||||
defp own_post?(_socket, _post), do: false
|
||||
|
||||
defp list_posts(_socket, event_id) do
|
||||
Posts.list_posts(event_id, [:event, :reactions, :user])
|
||||
end
|
||||
@@ -898,6 +959,35 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
end
|
||||
end
|
||||
|
||||
defp refresh_current_interaction(socket, preserve_state \\ false) do
|
||||
interaction =
|
||||
Interactions.get_active_interaction(socket.assigns.event, socket.assigns.state.position)
|
||||
|
||||
same_interaction =
|
||||
preserve_state && same_interaction?(socket.assigns.current_interaction, interaction)
|
||||
|
||||
socket
|
||||
|> assign(:current_interaction, interaction)
|
||||
|> load_current_interaction(interaction, same_interaction)
|
||||
end
|
||||
|
||||
defp same_interaction?(%{id: current_id}, %{id: next_id}), do: current_id == next_id
|
||||
defp same_interaction?(_, _), do: false
|
||||
|
||||
defp assign_current_slide(socket, position) do
|
||||
presentation_file =
|
||||
Presentations.get_presentation_file!(socket.assigns.event.presentation_file.id)
|
||||
|
||||
slide_urls = Presentations.get_slide_urls(presentation_file)
|
||||
|
||||
socket
|
||||
|> assign(:slide_urls, slide_urls)
|
||||
|> assign(:current_slide_url, Enum.at(slide_urls, position))
|
||||
end
|
||||
|
||||
defp focus_key(%{__struct__: module, id: id}, _position), do: "#{module}:#{id}"
|
||||
defp focus_key(_, position), do: "slide:#{position}"
|
||||
|
||||
defp load_current_interaction(socket, %Polls.Poll{} = interaction, same_interaction) do
|
||||
poll = Polls.set_percentages(interaction)
|
||||
|
||||
|
||||
@@ -1,431 +1,482 @@
|
||||
<%= if @started || @is_leader do %>
|
||||
<div class="relative min-h-screen lg:flex lg:flex-col lg:items-center lg:w-full bg-black lg:bg-primary">
|
||||
<div class="relative w-full">
|
||||
<% focus_content? =
|
||||
not is_nil(@current_slide_url) ||
|
||||
match?(%Claper.Polls.Poll{}, @current_interaction) ||
|
||||
match?(%Claper.Forms.Form{}, @current_interaction) ||
|
||||
match?(%Claper.Quizzes.Quiz{}, @current_interaction) ||
|
||||
match?(%Claper.Embeds.Embed{attendee_visibility: true}, @current_interaction) %>
|
||||
<% interaction_mode? =
|
||||
match?(%Claper.Polls.Poll{}, @current_interaction) ||
|
||||
match?(%Claper.Forms.Form{}, @current_interaction) ||
|
||||
match?(%Claper.Quizzes.Quiz{}, @current_interaction) %>
|
||||
<div class="h-[100dvh] overflow-hidden bg-black lg:bg-primary-700">
|
||||
<main
|
||||
id="attendee-room"
|
||||
class="relative mx-auto grid h-[100dvh] w-full max-w-lg grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden bg-black font-display shadow-2xl"
|
||||
>
|
||||
<div
|
||||
id="side-menu-shadow"
|
||||
phx-click={toggle_side_menu()}
|
||||
class="hidden fixed z-50 h-screen bg-black/70 w-full"
|
||||
class="fixed inset-0 z-50 hidden bg-black/70"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<aside
|
||||
id="side-menu"
|
||||
class="hidden fixed h-screen w-64 bg-white rounded-r-lg flex z-[60] px-4 flex-col justify-start lg:left-0 animate__faster"
|
||||
class="fixed inset-y-0 left-0 z-[60] hidden w-64 flex-col rounded-r-2xl bg-white px-4 text-gray-900 shadow-2xl animate__faster"
|
||||
>
|
||||
<div>
|
||||
<img src="/images/logo-large-black.svg" class="h-16 my-3" />
|
||||
|
||||
<span class="font-bold text-xl">{@event.name}</span>
|
||||
</div>
|
||||
|
||||
<img src="/images/logo-large-black.svg" class="my-3 h-16" alt="Claper" />
|
||||
<span class="text-xl font-bold">{@event.name}</span>
|
||||
<a
|
||||
class="flex items-center px-3 py-2 bg-gray-200 mb-15 rounded-lg mt-5"
|
||||
class="mt-5 flex items-center rounded-lg bg-gray-200 px-3 py-2"
|
||||
href={~p"/?disconnected_from=#{@event.uuid}"}
|
||||
>
|
||||
<img src="/images/icons/exit-outline.svg" class="h-5 mr-3" />
|
||||
<img src="/images/icons/exit-outline.svg" class="mr-3 h-5" />
|
||||
<span>{gettext("Leave")}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
id="content"
|
||||
class="w-full bg-black fixed z-10 lg:w-1/3"
|
||||
style="box-shadow: 0px 15px 14px 1px rgba(0,0,0,0.75); -webkit-box-shadow: 0px 15px 14px 1px rgba(0,0,0,0.75); -moz-box-shadow: 0px 15px 14px 1px rgba(0,0,0,0.75);"
|
||||
>
|
||||
<div id="banner" class="hidden w-full bg-gray-800 text-center" phx-hook="EmbeddedBanner">
|
||||
<a href="https://claper.co" target="_blank" class="text-xs text-white py-3 w-full">
|
||||
{gettext("Create your next presentation with")}
|
||||
<span class="underline">Claper</span>
|
||||
</a>
|
||||
<div
|
||||
id="connection-status"
|
||||
class="pointer-events-none absolute inset-x-4 top-16 z-50 hidden items-center justify-center rounded-full bg-supporting-yellow-100 px-4 py-2 text-sm font-semibold text-supporting-yellow-900 shadow-lg"
|
||||
>
|
||||
{gettext("Reconnecting...")}
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-5 py-3">
|
||||
|
||||
<div
|
||||
id="identity-menu"
|
||||
phx-click-away={JS.hide(to: "#identity-menu")}
|
||||
class="absolute bottom-16 left-2 z-40 hidden w-64 rounded-2xl border border-white/10 bg-gray-900 p-2 text-white shadow-2xl"
|
||||
>
|
||||
<p class="px-3 pb-2 pt-1 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
{gettext("Post as")}
|
||||
</p>
|
||||
<button
|
||||
phx-click={toggle_side_menu()}
|
||||
class="bg-primary rounded-full text-sm px-3 py-1 text-white uppercase flex items-center"
|
||||
>
|
||||
<img src="/images/icons/menu-outline.svg" class="h-6" />
|
||||
<span class="ml-1">#{@event.code}</span>
|
||||
</button>
|
||||
|
||||
<div class="inline-flex justify-between items-center text-white text-sm">
|
||||
<img src="/images/icons/online-users.svg" class="h-6 mr-2" />
|
||||
<span id="counter" phx-update="ignore" phx-hook="UpdateAttendees">
|
||||
{@attendees_nb}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= case @current_interaction do %>
|
||||
<% %Claper.Polls.Poll{} -> %>
|
||||
<div
|
||||
id="poll-wrapper-parent"
|
||||
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 lg:px-7 pb-6 max-h-screen overflow-y-auto"
|
||||
>
|
||||
<div class="transition-all" id="poll-wrapper">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.PollComponent}
|
||||
id={"#{@current_interaction.id}-poll"}
|
||||
poll={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
selected_poll_opt={@selected_poll_opt}
|
||||
current_poll_vote={@current_poll_vote}
|
||||
show_results={@current_interaction.show_results}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% %Claper.Forms.Form{} -> %>
|
||||
<div
|
||||
id="form-wrapper-parent"
|
||||
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto"
|
||||
>
|
||||
<div class="transition-all" id="form-wrapper">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.FormComponent}
|
||||
id={"#{@current_interaction.id}-form"}
|
||||
form={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
current_form_submit={@current_form_submit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% %Claper.Embeds.Embed{} -> %>
|
||||
<div
|
||||
:if={@current_interaction.attendee_visibility == true}
|
||||
id="embed-wrapper-parent"
|
||||
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto"
|
||||
>
|
||||
<div class="transition-all" id="embed-wrapper">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.EmbedComponent}
|
||||
id={"#{@current_interaction.id}-embed"}
|
||||
embed={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% %Claper.Quizzes.Quiz{} -> %>
|
||||
<div
|
||||
id="quiz-wrapper-parent"
|
||||
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto"
|
||||
>
|
||||
<div class="transition-all" id="quiz-wrapper">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.QuizComponent}
|
||||
id={"#{@current_interaction.id}-quiz"}
|
||||
quiz={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
selected_quiz_question_opts={@selected_quiz_question_opts}
|
||||
current_quiz_question_idx={@current_quiz_question_idx}
|
||||
current_quiz_responses={@current_quiz_responses}
|
||||
quiz_score={@quiz_score}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% _ -> %>
|
||||
<!-- Handle any other types of interactions here if needed -->
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
class="flex flex-col space-y-4 px-5 pt-20 pb-32 lg:w-1/3 bg-black min-h-screen"
|
||||
id="post-list"
|
||||
phx-update="stream"
|
||||
data-posts-nb={Enum.count(@streams.posts)}
|
||||
phx-hook="Scroll"
|
||||
data-target="body"
|
||||
>
|
||||
<.live_component
|
||||
:for={{id, post} <- @streams.posts}
|
||||
module={ClaperWeb.EventLive.PostComponent}
|
||||
id={id}
|
||||
post={post}
|
||||
leaders={@leaders}
|
||||
is_leader={@is_leader}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
reaction_enabled={@state.message_reaction_enabled}
|
||||
liked_posts={@like_posts}
|
||||
loved_posts={@love_posts}
|
||||
loled_posts={@lol_posts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if @post_count == 0 && @state.chat_enabled do %>
|
||||
<div class="text-2xl text-white block fixed bottom-32 left-0 w-full lg:w-1/3 lg:left-1/2 lg:transform lg:-translate-x-1/2 text-center opacity-30">
|
||||
<span>{gettext("Be the first to react !")}</span>
|
||||
<img src="/images/icons/arrow-white.svg" class="h-24 rotate-180 ml-12 mt-8" />
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
id="reacts"
|
||||
phx-hook="GlobalReacts"
|
||||
data-class-name="h-12"
|
||||
class="fixed right-5 bottom-12 z-30 w-1/3"
|
||||
phx-update="ignore"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="nickname-popup"
|
||||
class="hidden fixed bottom-0 h-36 w-full lg:w-1/3 lg:mx-auto bg-black text-white z-40 shadow-md rounded-md p-4 flex flex-col gap-y-2 animate__faster"
|
||||
>
|
||||
<%= if @state.anonymous_chat_enabled do %>
|
||||
<button
|
||||
phx-click={JS.push("set-nickname") |> toggle_nickname_popup()}
|
||||
:if={@state.anonymous_chat_enabled}
|
||||
id="setAnonymous"
|
||||
type="button"
|
||||
phx-click={JS.push("set-nickname") |> JS.hide(to: "#identity-menu")}
|
||||
phx-value-nickname=""
|
||||
phx-hook="EmptyNickname"
|
||||
id="setAnonymous"
|
||||
class="w-full bg-gray-900 text-left text-white px-3 py-2 rounded-md flex space-x-2 items-center"
|
||||
data-storage-key={"nickname:#{@event.uuid}"}
|
||||
class="flex min-h-11 w-full items-center gap-3 rounded-xl px-3 py-2 text-left hover:bg-white/10"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M3 11h18"></path>
|
||||
<path d="M5 11v-4a3 3 0 0 1 3 -3h8a3 3 0 0 1 3 3v4"></path>
|
||||
<path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M10 17h4"></path>
|
||||
</svg>
|
||||
<span>{gettext("Anonymous")}</span>
|
||||
<span class="grid h-8 w-8 place-items-center rounded-full bg-gray-700">?</span>
|
||||
<span class="font-semibold">{gettext("Anonymous")}</span>
|
||||
</button>
|
||||
<% else %>
|
||||
<button class="w-full bg-gray-900 opacity-50 text-left text-white px-3 py-2 rounded-md flex space-x-2 items-center cursor-default">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M3 11h18"></path>
|
||||
<path d="M5 11v-4a3 3 0 0 1 3 -3h8a3 3 0 0 1 3 3v4"></path>
|
||||
<path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M10 17h4"></path>
|
||||
</svg>
|
||||
<span>{gettext("Anonymous")} ({gettext("disabled")})</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
id="nicknamepicker"
|
||||
data-prompt={gettext("Enter your name")}
|
||||
data-close={toggle_nickname_popup()}
|
||||
phx-hook="NicknamePicker"
|
||||
class="w-full bg-gray-900 text-left text-white px-3 py-2 rounded-md flex space-x-2 items-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
<button
|
||||
:if={!@state.anonymous_chat_enabled}
|
||||
type="button"
|
||||
disabled
|
||||
class="flex min-h-11 w-full cursor-not-allowed items-center gap-3 rounded-xl px-3 py-2 text-left opacity-40"
|
||||
>
|
||||
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
|
||||
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
|
||||
</svg>
|
||||
<span>{gettext("Use your name")}</span>
|
||||
</button>
|
||||
<button
|
||||
phx-click={toggle_nickname_popup()}
|
||||
class="w-full text-left text-primary-500 text-sm px-3 py-0 rounded-md"
|
||||
>
|
||||
{gettext("Close")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class={"fixed z-30 w-full " <> if @state.message_reaction_enabled, do: "bottom-12", else: "bottom-3"}>
|
||||
<div
|
||||
:if={
|
||||
@transcription_config && @transcription_config.enabled &&
|
||||
@transcription_config.visibility in ["both", "attendee"] && @transcription_text != ""
|
||||
}
|
||||
class="w-full lg:w-1/3 lg:mx-auto px-5 mb-2"
|
||||
>
|
||||
<div class="bg-black/80 rounded-lg px-4 py-2 text-center">
|
||||
<p class="text-sm text-white font-medium">{@transcription_text}</p>
|
||||
</div>
|
||||
<span class="grid h-8 w-8 place-items-center rounded-full bg-gray-700">?</span>
|
||||
<span class="font-semibold">{gettext("Anonymous")} ({gettext("disabled")})</span>
|
||||
</button>
|
||||
<button
|
||||
id="nicknamepicker"
|
||||
type="button"
|
||||
data-prompt={gettext("Enter your name")}
|
||||
data-invalid={gettext("Nickname must be between 2 and 20 characters")}
|
||||
data-close={JS.hide(to: "#identity-menu")}
|
||||
data-storage-key={"nickname:#{@event.uuid}"}
|
||||
phx-hook="NicknamePicker"
|
||||
class="flex min-h-11 w-full items-center gap-3 rounded-xl px-3 py-2 text-left hover:bg-white/10"
|
||||
>
|
||||
<span class="grid h-8 w-8 place-items-center rounded-full bg-primary-500">
|
||||
{if @nickname in [nil, ""], do: "A", else: String.first(@nickname)}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-xs text-gray-400">{gettext("Nickname")}</span>
|
||||
<span class="block truncate font-semibold">
|
||||
{if @nickname in [nil, ""], do: gettext("Set your nickname"), else: @nickname}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @state.chat_enabled do %>
|
||||
<%= if !@state.anonymous_chat_enabled && (@nickname && @nickname == "") do %>
|
||||
<.form
|
||||
:let={f}
|
||||
for={@post_changeset}
|
||||
id="nickname-form"
|
||||
class="w-full lg:w-1/3 lg:mx-auto"
|
||||
phx-submit="save-nickname"
|
||||
<header
|
||||
id="room-topbar"
|
||||
class="relative z-30 bg-black px-4 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))]"
|
||||
>
|
||||
<div id="banner" class="hidden w-full pb-2 text-center" phx-hook="EmbeddedBanner">
|
||||
<a href="https://claper.co" target="_blank" class="text-xs text-gray-400">
|
||||
{gettext("Create your next presentation with")} <span class="underline">Claper</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click={toggle_side_menu()}
|
||||
class="inline-flex min-h-11 items-center gap-2 rounded-full bg-primary-500 px-4 text-sm font-bold uppercase text-white shadow-lg shadow-primary-900/30"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg text-base px-3 py-2 mx-5 relative"
|
||||
style="
|
||||
background: rgb(17,134,213);
|
||||
background: linear-gradient(333deg, rgba(17,134,213,0.4962359943977591) 0%, rgba(163,39,255,0.5046393557422969) 100%);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(11.5px);
|
||||
-webkit-backdrop-filter: blur(11.5px);"
|
||||
>
|
||||
{text_input(f, :name,
|
||||
class:
|
||||
"bg-transparent outline-hidden w-full text-white h-10 placeholder-white resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
|
||||
placeholder: gettext("Enter your name")
|
||||
)}
|
||||
<p class="font-semibold text-sm">
|
||||
{error_tag(f, :name)}
|
||||
</p>
|
||||
{submit(gettext("Join"), class: "absolute right-5 top-2 p-2 bg-white rounded-md")}
|
||||
<img src="/images/icons/menu-outline.svg" class="h-5 w-5" />
|
||||
<span>#{@event.code}</span>
|
||||
</button>
|
||||
|
||||
<div class="ml-auto inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-white">
|
||||
<img src="/images/icons/online-users.svg" class="h-5 w-5" />
|
||||
<span id="counter" phx-update="ignore" phx-hook="UpdateAttendees">
|
||||
{@attendees_nb}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
id="focus-slot"
|
||||
phx-hook="AttendeeFocus"
|
||||
data-focus-key={focus_key(@current_interaction, @state.position)}
|
||||
data-collapse-key={"focus-slot:#{@event.uuid}"}
|
||||
data-interaction-mode={to_string(interaction_mode?)}
|
||||
class={[
|
||||
"relative z-10 min-h-0 overflow-hidden border-y border-white/10 bg-gray-950 transition-[height] duration-300",
|
||||
focus_content? && "h-[40dvh] min-h-40 max-h-[26rem]",
|
||||
!focus_content? && "h-12"
|
||||
]}
|
||||
>
|
||||
<div
|
||||
id="new-interaction-badge"
|
||||
class="pointer-events-none absolute left-1/2 top-3 z-30 hidden -translate-x-1/2 rounded-full bg-primary-500 px-3 py-1 text-xs font-bold text-white shadow-lg"
|
||||
>
|
||||
{gettext("New interaction")}
|
||||
</div>
|
||||
|
||||
<%= case @current_interaction do %>
|
||||
<% %Claper.Polls.Poll{} -> %>
|
||||
<div class="h-full overflow-y-auto overscroll-contain p-3">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.PollComponent}
|
||||
id={"#{@current_interaction.id}-poll"}
|
||||
poll={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
selected_poll_opt={@selected_poll_opt}
|
||||
current_poll_vote={@current_poll_vote}
|
||||
show_results={@current_interaction.show_results}
|
||||
focus_mode
|
||||
/>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<% %Claper.Forms.Form{} -> %>
|
||||
<div class="h-full overflow-y-auto overscroll-contain p-3">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.FormComponent}
|
||||
id={"#{@current_interaction.id}-form"}
|
||||
form={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
current_form_submit={@current_form_submit}
|
||||
focus_mode
|
||||
/>
|
||||
</div>
|
||||
<% %Claper.Quizzes.Quiz{} -> %>
|
||||
<div class="h-full overflow-y-auto overscroll-contain p-3">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.QuizComponent}
|
||||
id={"#{@current_interaction.id}-quiz"}
|
||||
quiz={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
selected_quiz_question_opts={@selected_quiz_question_opts}
|
||||
current_quiz_question_idx={@current_quiz_question_idx}
|
||||
current_quiz_responses={@current_quiz_responses}
|
||||
quiz_score={@quiz_score}
|
||||
focus_mode
|
||||
/>
|
||||
</div>
|
||||
<% %Claper.Embeds.Embed{attendee_visibility: true} -> %>
|
||||
<div id="focus-media" class="relative h-full w-full bg-black">
|
||||
<.live_component
|
||||
id={"focus-embed-#{@current_interaction.id}"}
|
||||
module={ClaperWeb.EventLive.EmbedIframeComponent}
|
||||
provider={@current_interaction.provider}
|
||||
content={@current_interaction.content}
|
||||
title={@current_interaction.title}
|
||||
/>
|
||||
</div>
|
||||
<% _ -> %>
|
||||
<%= if @current_slide_url do %>
|
||||
<div id="focus-media" class="relative h-full w-full bg-black">
|
||||
<img
|
||||
src={@current_slide_url}
|
||||
alt={gettext("Current presentation slide")}
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex h-full items-center justify-between gap-3 px-4 text-sm text-gray-400">
|
||||
<span>{gettext("Waiting for content")}</span>
|
||||
<span class="h-2 w-2 animate-pulse rounded-full bg-primary-400"></span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
:if={focus_content? && !interaction_mode?}
|
||||
type="button"
|
||||
data-focus-fullscreen
|
||||
aria-label={gettext("Open fullscreen")}
|
||||
class="absolute right-3 top-3 z-20 grid h-11 w-11 place-items-center rounded-full bg-black/65 text-white backdrop-blur"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 3H3v5M16 3h5v5M8 21H3v-5m13 5h5v-5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:if={focus_content? && !interaction_mode?}
|
||||
type="button"
|
||||
data-focus-collapse
|
||||
aria-label={gettext("Hide presentation")}
|
||||
class="absolute right-16 top-3 z-20 grid h-11 w-11 place-items-center rounded-full bg-black/65 text-white backdrop-blur"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m6 15 6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
:if={focus_content? && !interaction_mode?}
|
||||
data-focus-collapsed-bar
|
||||
class="hidden h-full items-center px-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-focus-show
|
||||
class="flex min-h-11 w-full items-center justify-between text-sm font-semibold text-white"
|
||||
>
|
||||
<span>{gettext("Show presentation")}</span>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={
|
||||
!interaction_mode? && @transcription_config && @transcription_config.enabled &&
|
||||
@transcription_config.visibility in ["both", "attendee"]
|
||||
}
|
||||
id="focus-captions"
|
||||
class="absolute inset-x-0 bottom-0 z-20"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-caption-toggle
|
||||
aria-label={gettext("Toggle captions")}
|
||||
class="absolute bottom-2 right-2 grid h-11 w-11 place-items-center rounded-full bg-black/70 text-xs font-black text-white backdrop-blur"
|
||||
>
|
||||
CC
|
||||
</button>
|
||||
<div
|
||||
data-caption-text
|
||||
class={[
|
||||
"mx-14 mb-2 rounded-lg bg-black/80 px-3 py-2 text-center text-sm font-medium text-white backdrop-blur",
|
||||
@transcription_text == "" && "hidden"
|
||||
]}
|
||||
>
|
||||
{@transcription_text}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="chat-panel" class="relative min-h-0 overflow-visible bg-black">
|
||||
<div
|
||||
id="chat-feed"
|
||||
class={[
|
||||
"h-full space-y-3 overflow-y-auto overscroll-contain px-4 pt-4",
|
||||
@state.message_reaction_enabled && "pb-40",
|
||||
!@state.message_reaction_enabled && "pb-24"
|
||||
]}
|
||||
data-room-reactions={to_string(@state.message_reaction_enabled)}
|
||||
phx-update="stream"
|
||||
phx-hook="RoomFeed"
|
||||
data-chip="#new-messages-chip"
|
||||
data-posts-nb={@post_count}
|
||||
>
|
||||
<.live_component
|
||||
:for={{id, post} <- @streams.posts}
|
||||
module={ClaperWeb.EventLive.PostComponent}
|
||||
id={id}
|
||||
post={post}
|
||||
leaders={@leaders}
|
||||
is_leader={@is_leader}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
reaction_enabled={@state.message_reaction_enabled}
|
||||
liked_posts={@like_posts}
|
||||
loved_posts={@love_posts}
|
||||
loled_posts={@lol_posts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@post_count == 0 && @state.chat_enabled}
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center px-8 text-center text-sm text-gray-500"
|
||||
>
|
||||
{gettext("Be the first to ask a question or share a thought.")}
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="new-messages-chip"
|
||||
type="button"
|
||||
class="absolute bottom-20 left-1/2 z-20 hidden -translate-x-1/2 rounded-full bg-primary-500 px-4 py-2 text-xs font-bold text-white shadow-xl"
|
||||
>
|
||||
<span data-unread-count></span> {gettext("new messages")} ↓
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="reacts"
|
||||
phx-hook="GlobalReacts"
|
||||
data-class-name="h-12 w-12"
|
||||
class="pointer-events-none absolute inset-0 z-10 overflow-hidden"
|
||||
phx-update="ignore"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@state.message_reaction_enabled}
|
||||
id="room-reaction-fab"
|
||||
phx-hook="RoomReactionFab"
|
||||
class="absolute bottom-20 left-4 z-20 flex flex-col items-center gap-2"
|
||||
>
|
||||
<div
|
||||
data-reaction-picker
|
||||
role="menu"
|
||||
class="hidden flex-col-reverse gap-2 rounded-full bg-gray-900/95 p-2 shadow-2xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-reaction="heart"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Heart")}
|
||||
class="grid h-11 w-11 place-items-center rounded-full hover:bg-white/10"
|
||||
>
|
||||
<img src="/images/icons/heart.svg" class="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-reaction="clap"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Clap")}
|
||||
class="grid h-11 w-11 place-items-center rounded-full hover:bg-white/10"
|
||||
>
|
||||
<img src="/images/icons/clap.svg" class="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-reaction="hundred"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Hundred")}
|
||||
class="grid h-11 w-11 place-items-center rounded-full hover:bg-white/10"
|
||||
>
|
||||
<img src="/images/icons/hundred.svg" class="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-reaction="raisehand"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Raise hand")}
|
||||
class="grid h-11 w-11 place-items-center rounded-full hover:bg-white/10"
|
||||
>
|
||||
<img src="/images/icons/raisehand.svg" class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-reaction-trigger
|
||||
aria-label={gettext("Send a live reaction")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
class="grid h-14 w-14 place-items-center rounded-full border-4 border-gray-900 bg-supporting-yellow-100 shadow-xl"
|
||||
>
|
||||
<img data-reaction-icon src="/images/icons/heart.svg" class="h-7 w-7" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
id="room-composer"
|
||||
class="absolute inset-x-0 bottom-0 z-30 bg-transparent px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2"
|
||||
>
|
||||
<%= if @state.chat_enabled do %>
|
||||
<.form
|
||||
:let={f}
|
||||
for={@post_changeset}
|
||||
id="post-form"
|
||||
class="w-full lg:w-1/3 lg:mx-auto"
|
||||
class="attendee-composer flex items-center gap-1.5 rounded-2xl border border-white/20 px-1.5 py-1 shadow-2xl"
|
||||
phx-hook="PostForm"
|
||||
data-nickname={@nickname}
|
||||
data-storage-key={"post-draft:#{@event.uuid}"}
|
||||
phx-submit="save"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg text-base px-3 py-2 mx-5 relative"
|
||||
style="
|
||||
background: rgb(17,134,213);
|
||||
background: linear-gradient(333deg, rgba(17,134,213,0.4962359943977591) 0%, rgba(163,39,255,0.5046393557422969) 100%);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(11.5px);
|
||||
-webkit-backdrop-filter: blur(11.5px);"
|
||||
<button
|
||||
id="composer-identity-button"
|
||||
type="button"
|
||||
aria-label={gettext("Choose identity")}
|
||||
phx-click={JS.toggle(to: "#identity-menu")}
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-black/60 text-xs font-bold text-white"
|
||||
>
|
||||
<div class="ml-0">
|
||||
<a
|
||||
href="#"
|
||||
phx-click={toggle_nickname_popup()}
|
||||
class="px-2 py-0.5 text-xs text-white rounded-full w-fit bg-gray-900 flex gap-x-1 items-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
<%= if @nickname && @nickname == "" do %>
|
||||
<span>{gettext("Anonymous")}</span>
|
||||
<% else %>
|
||||
<span>{@nickname}</span>
|
||||
<% end %>
|
||||
</a>
|
||||
</div>
|
||||
{if @nickname in [nil, ""], do: "A", else: String.first(@nickname)}
|
||||
</button>
|
||||
|
||||
<button class="absolute right-5 top-6 opacity-50" id="submitBtn">
|
||||
<img src="/images/icons/send.svg" class="h-6" />
|
||||
</button>
|
||||
{textarea(f, :body,
|
||||
id: "postFormTA",
|
||||
disabled: !@state.anonymous_chat_enabled && @nickname in [nil, ""],
|
||||
rows: 1,
|
||||
maxlength: 255,
|
||||
class:
|
||||
"min-h-9 max-h-16 min-w-0 flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm leading-5 text-white outline-hidden placeholder:text-white/70 focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
placeholder:
|
||||
if(!@state.anonymous_chat_enabled && @nickname in [nil, ""],
|
||||
do: gettext("Set your nickname to participate"),
|
||||
else: gettext("Ask, comment...")
|
||||
)
|
||||
)}
|
||||
|
||||
<div class="flex space-x-2 items-center">
|
||||
{textarea(f, :body,
|
||||
id: "postFormTA",
|
||||
class:
|
||||
"bg-transparent outline-hidden w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
|
||||
placeholder: gettext("Ask, comment...")
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{submit("Save", phx_disable_with: "Saving...", id: "hiddenSubmit", class: "hidden")}
|
||||
<button
|
||||
type="submit"
|
||||
id="submitBtn"
|
||||
disabled
|
||||
aria-label={gettext("Send message")}
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-black/20 text-white opacity-50 transition disabled:cursor-not-allowed"
|
||||
>
|
||||
<img src="/images/icons/send.svg" class="h-5 w-5" />
|
||||
</button>
|
||||
</.form>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div id="post-form" class="w-full lg:w-1/3 lg:mx-auto">
|
||||
<div
|
||||
class="rounded-lg text-base px-4 py-2 mx-5 relative"
|
||||
style="
|
||||
background: rgb(17,134,213);
|
||||
background: linear-gradient(333deg, rgba(17,134,213,0.4962359943977591) 0%, rgba(163,39,255,0.5046393557422969) 100%);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(11.5px);
|
||||
-webkit-backdrop-filter: blur(11.5px);"
|
||||
>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<div class="opacity-50 bg-transparent outline-hidden w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto">
|
||||
{gettext("Messages deactivated")}
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="attendee-composer flex min-h-10 items-center rounded-2xl border border-white/20 px-3 text-sm text-white/60 shadow-2xl">
|
||||
{gettext("Messages deactivated")}
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@state.message_reaction_enabled}
|
||||
class="flex space-x-6 fixed justify-center bottom-3 w-full lg:w-1/3 lg:mx-auto"
|
||||
>
|
||||
<a
|
||||
phx-click="global-react"
|
||||
phx-hook="ClickFeedback"
|
||||
id="react-heart"
|
||||
class="cursor-pointer"
|
||||
phx-value-type="heart"
|
||||
>
|
||||
<img class="h-6" src="/images/icons/heart.svg" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
phx-click="global-react"
|
||||
phx-hook="ClickFeedback"
|
||||
id="react-clap"
|
||||
class="cursor-pointer"
|
||||
phx-value-type="clap"
|
||||
>
|
||||
<img class="h-6" src="/images/icons/clap.svg" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
phx-click="global-react"
|
||||
phx-hook="ClickFeedback"
|
||||
id="react-hundred"
|
||||
class="cursor-pointer"
|
||||
phx-value-type="hundred"
|
||||
>
|
||||
<img class="h-6" src="/images/icons/hundred.svg" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
phx-click="global-react"
|
||||
phx-hook="ClickFeedback"
|
||||
id="react-raisehand"
|
||||
class="cursor-pointer"
|
||||
phx-value-type="raisehand"
|
||||
>
|
||||
<img class="h-6" src="/images/icons/raisehand.svg" />
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="ticket-stage">
|
||||
@@ -460,16 +511,12 @@
|
||||
</div>
|
||||
<div class="cd-sep">:</div>
|
||||
<div class="cd-cell">
|
||||
<div class="cd-num">
|
||||
{if @remaining_minutes < 10, do: "0"}{@remaining_minutes}
|
||||
</div>
|
||||
<div class="cd-num">{if @remaining_minutes < 10, do: "0"}{@remaining_minutes}</div>
|
||||
<div class="cd-label">{gettext("minutes")}</div>
|
||||
</div>
|
||||
<div class="cd-sep">:</div>
|
||||
<div class="cd-cell">
|
||||
<div class="cd-num">
|
||||
{if @remaining_seconds < 10, do: "0"}{@remaining_seconds}
|
||||
</div>
|
||||
<div class="cd-num">{if @remaining_seconds < 10, do: "0"}{@remaining_seconds}</div>
|
||||
<div class="cd-label">{gettext("seconds")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -361,6 +361,7 @@
|
||||
module={ClaperWeb.EventLive.EmbedIframeComponent}
|
||||
provider={embed.provider}
|
||||
content={embed.content}
|
||||
title={embed.title}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
337
test/claper_web/live/event_live/interaction_components_test.exs
Normal file
337
test/claper_web/live/event_live/interaction_components_test.exs
Normal file
@@ -0,0 +1,337 @@
|
||||
defmodule ClaperWeb.EventLive.InteractionComponentsTest do
|
||||
use ClaperWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Claper.Embeds.Embed
|
||||
alias Claper.Forms.Form
|
||||
alias Claper.Polls.{Poll, PollOpt}
|
||||
alias Claper.Quizzes.{Quiz, QuizQuestion, QuizQuestionOpt}
|
||||
alias ClaperWeb.EventLive.{EmbedComponent, FormComponent, PollComponent, QuizComponent}
|
||||
|
||||
test "poll uses the feature preview card and selected option styling" do
|
||||
poll = %Poll{
|
||||
title: "Which topic should be next?",
|
||||
multiple: false,
|
||||
poll_opts: [
|
||||
%PollOpt{id: 1, content: "LiveView", percentage: 0.0, vote_count: 0}
|
||||
]
|
||||
}
|
||||
|
||||
document =
|
||||
PollComponent
|
||||
|> render_component(
|
||||
id: "poll-component",
|
||||
poll: poll,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
selected_poll_opt: ["0"],
|
||||
current_poll_vote: [],
|
||||
show_results: false
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert_card_shell(document, "#extended-poll", "#poll-pane")
|
||||
assert Floki.find(document, "#collapsed-poll > button") != []
|
||||
option_classes = classes(document, "#poll-opt-0")
|
||||
vote_classes = classes(document, ~s(button[phx-click="vote"]))
|
||||
|
||||
assert "bg-primary-900/40" in option_classes
|
||||
assert "py-2" in option_classes
|
||||
assert "shrink-0" in option_classes
|
||||
refute "min-h-11" in option_classes
|
||||
assert Floki.attribute(document, "#poll-opt-0", "aria-pressed") == ["true"]
|
||||
assert "btn-gradient" in vote_classes
|
||||
assert "w-full" in vote_classes
|
||||
|
||||
submitted_document =
|
||||
PollComponent
|
||||
|> render_component(
|
||||
id: "submitted-poll-component",
|
||||
poll: poll,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
selected_poll_opt: [],
|
||||
current_poll_vote: [%{poll_opt_id: 1}],
|
||||
show_results: false
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert_submitted_button(submitted_document)
|
||||
end
|
||||
|
||||
test "form uses the feature preview card and field styling" do
|
||||
form = %Form{
|
||||
title: "Tell us what you think",
|
||||
fields: [%{name: "Feedback", type: "text", required: false}]
|
||||
}
|
||||
|
||||
document =
|
||||
FormComponent
|
||||
|> render_component(
|
||||
id: "form-component",
|
||||
form: form,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
current_form_submit: nil
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert_card_shell(document, "#extended-form", "#form-pane")
|
||||
assert Floki.find(document, "#collapsed-form > button") != []
|
||||
submit_classes = classes(document, ~s(button[type="submit"]))
|
||||
|
||||
assert "bg-gray-800" in classes(document, ~s(input[name="form_submit[Feedback]"]))
|
||||
assert "btn-gradient" in submit_classes
|
||||
assert "w-full" in submit_classes
|
||||
|
||||
submitted_document =
|
||||
FormComponent
|
||||
|> render_component(
|
||||
id: "submitted-form-component",
|
||||
form: form,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
current_form_submit: %Claper.Forms.FormSubmit{response: %{"Feedback" => "Done"}}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert_submitted_button(submitted_document)
|
||||
|
||||
assert Floki.attribute(
|
||||
submitted_document,
|
||||
~s(input[name="form_submit[Feedback]"]),
|
||||
"readonly"
|
||||
) ==
|
||||
["readonly"]
|
||||
end
|
||||
|
||||
test "quiz uses the feature preview card and selected answer styling" do
|
||||
option = %QuizQuestionOpt{id: 1, content: "10-20 minutes", is_correct: true}
|
||||
|
||||
question = %QuizQuestion{
|
||||
id: 1,
|
||||
content: "How long can audiences stay focused?",
|
||||
quiz_question_opts: [option]
|
||||
}
|
||||
|
||||
quiz = %Quiz{
|
||||
title: "Attention spans",
|
||||
allow_anonymous: true,
|
||||
show_results: false,
|
||||
quiz_questions: [question, %{question | id: 2}, %{question | id: 3}]
|
||||
}
|
||||
|
||||
document =
|
||||
QuizComponent
|
||||
|> render_component(
|
||||
id: "quiz-component",
|
||||
quiz: quiz,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
selected_quiz_question_opts: [option],
|
||||
current_quiz_question_idx: 2,
|
||||
current_quiz_responses: [],
|
||||
quiz_score: {0, 1}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert_card_shell(document, "#extended-quiz", "#quiz-pane")
|
||||
assert Floki.find(document, "#collapsed-quiz > button") != []
|
||||
answer_classes = classes(document, ~s(button[phx-click="select-quiz-question-opt"]))
|
||||
submit_classes = classes(document, ~s(button[phx-click="submit-quiz"]))
|
||||
|
||||
assert "bg-primary-900/40" in answer_classes
|
||||
assert "py-2" in answer_classes
|
||||
refute "min-h-11" in answer_classes
|
||||
|
||||
assert Floki.attribute(
|
||||
document,
|
||||
~s(button[phx-click="select-quiz-question-opt"]),
|
||||
"aria-pressed"
|
||||
) ==
|
||||
["true"]
|
||||
|
||||
assert "btn-gradient" in submit_classes
|
||||
assert "flex-1" in submit_classes
|
||||
|
||||
assert document
|
||||
|> Floki.find("#quiz-actions > button")
|
||||
|> Enum.map(&(&1 |> Floki.text() |> String.trim())) == ["Back", "Submit"]
|
||||
|
||||
next_document =
|
||||
QuizComponent
|
||||
|> render_component(
|
||||
id: "next-quiz-component",
|
||||
quiz: quiz,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
selected_quiz_question_opts: [option],
|
||||
current_quiz_question_idx: 1,
|
||||
current_quiz_responses: [],
|
||||
quiz_score: {0, 1}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert next_document
|
||||
|> Floki.find("#quiz-actions > button")
|
||||
|> Enum.map(&(&1 |> Floki.text() |> String.trim())) == ["Back", "Next"]
|
||||
|
||||
assert "flex-1" in classes(next_document, ~s(button[phx-click="next-question"]))
|
||||
|
||||
sign_in_document =
|
||||
QuizComponent
|
||||
|> render_component(
|
||||
id: "sign-in-quiz-component",
|
||||
quiz: %{quiz | allow_anonymous: false},
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
selected_quiz_question_opts: [option],
|
||||
current_quiz_question_idx: 2,
|
||||
current_quiz_responses: [],
|
||||
quiz_score: {0, 1}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert "text-[10px]" in classes(sign_in_document, "#quiz-sign-in-prompt")
|
||||
|
||||
assert sign_in_document
|
||||
|> Floki.find("#quiz-actions > div")
|
||||
|> List.first()
|
||||
|> elem(2)
|
||||
|> Enum.filter(&match?({_, _, _}, &1))
|
||||
|> Enum.map(&elem(&1, 0)) == ["a", "p"]
|
||||
|
||||
submitted_quiz = %{quiz | show_results: true}
|
||||
|
||||
submitted_document =
|
||||
QuizComponent
|
||||
|> render_component(
|
||||
id: "submitted-quiz-component",
|
||||
quiz: submitted_quiz,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
selected_quiz_question_opts: [],
|
||||
current_quiz_question_idx: 3,
|
||||
current_quiz_responses: [%{quiz_question_opt_id: 1}],
|
||||
quiz_score: {1, 1}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert Floki.find(submitted_document, "button[data-submitted]") == []
|
||||
|
||||
assert submitted_document
|
||||
|> Floki.find(~s(button[phx-click="show-quiz-results"]))
|
||||
|> Floki.text()
|
||||
|> String.trim() == "Show results"
|
||||
|
||||
assert "w-full" in classes(submitted_document, ~s(button[phx-click="show-quiz-results"]))
|
||||
|
||||
review_document =
|
||||
QuizComponent
|
||||
|> render_component(
|
||||
id: "review-quiz-component",
|
||||
quiz: submitted_quiz,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{},
|
||||
selected_quiz_question_opts: [],
|
||||
current_quiz_question_idx: 1,
|
||||
current_quiz_responses: [%{quiz_question_opt_id: 1}],
|
||||
quiz_score: {1, 1}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert review_document
|
||||
|> Floki.find("#quiz-review-actions > button")
|
||||
|> Enum.map(&(&1 |> Floki.text() |> String.trim())) == ["Back", "Next"]
|
||||
|
||||
assert "flex-1" in classes(
|
||||
review_document,
|
||||
~s(#quiz-review-actions button[phx-click="next-question"])
|
||||
)
|
||||
end
|
||||
|
||||
test "web content uses the feature preview card and fills a responsive frame" do
|
||||
embed = %Embed{
|
||||
title: "Watch the demo",
|
||||
provider: "youtube",
|
||||
content: "https://youtu.be/video-id"
|
||||
}
|
||||
|
||||
document =
|
||||
EmbedComponent
|
||||
|> render_component(
|
||||
id: "embed-component",
|
||||
embed: embed,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
assert_card_shell(document, "#extended-embed", "#embed-pane")
|
||||
assert Floki.find(document, "#collapsed-embed > button") != []
|
||||
assert "aspect-video" in classes(document, "#extended-embed > div:last-child")
|
||||
assert "h-full" in classes(document, "iframe")
|
||||
assert "w-full" in classes(document, "iframe")
|
||||
assert Floki.attribute(document, "iframe", "title") == ["Watch the demo"]
|
||||
end
|
||||
|
||||
test "custom web content is not cropped into a video aspect ratio" do
|
||||
embed = %Embed{
|
||||
title: "Interactive exercise",
|
||||
provider: "custom",
|
||||
content: ~s(<iframe height="450" src="https://example.com"></iframe>)
|
||||
}
|
||||
|
||||
document =
|
||||
EmbedComponent
|
||||
|> render_component(
|
||||
id: "custom-embed-component",
|
||||
embed: embed,
|
||||
current_user: nil,
|
||||
attendee_identifier: "attendee",
|
||||
event: %{}
|
||||
)
|
||||
|> Floki.parse_document!()
|
||||
|
||||
frame_classes = classes(document, "#extended-embed > div:last-child")
|
||||
|
||||
assert "overflow-x-auto" in frame_classes
|
||||
refute "aspect-video" in frame_classes
|
||||
refute "overflow-hidden" in frame_classes
|
||||
end
|
||||
|
||||
defp assert_card_shell(document, card_selector, close_selector) do
|
||||
assert "bg-gray-900" in classes(document, card_selector)
|
||||
assert "rounded-2xl" in classes(document, card_selector)
|
||||
assert "shadow-2xl" in classes(document, card_selector)
|
||||
assert Floki.attribute(document, close_selector, "aria-label") == ["Close"]
|
||||
end
|
||||
|
||||
defp assert_submitted_button(document) do
|
||||
assert "w-full" in classes(document, "button[data-submitted]")
|
||||
assert Floki.attribute(document, "button[data-submitted]", "disabled") == ["disabled"]
|
||||
|
||||
assert document
|
||||
|> Floki.find("button[data-submitted]")
|
||||
|> Floki.text()
|
||||
|> String.trim() == "Submitted"
|
||||
end
|
||||
|
||||
defp classes(document, selector) do
|
||||
document
|
||||
|> Floki.attribute(selector, "class")
|
||||
|> List.first()
|
||||
|> String.split()
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,11 @@ defmodule ClaperWeb.EventLive.ShowTest do
|
||||
use ClaperWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Claper.PresentationsFixtures
|
||||
import Claper.{AccountsFixtures, PostsFixtures, PresentationsFixtures}
|
||||
|
||||
setup [:register_and_log_in_user]
|
||||
|
||||
test "renders attendee menus above inputs and delegates nickname closing to the hook", %{
|
||||
test "renders the fixed attendee room stack and identity menu", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
@@ -17,16 +17,49 @@ defmodule ClaperWeb.EventLive.ShowTest do
|
||||
|
||||
document = Floki.parse_document!(html)
|
||||
|
||||
assert "z-50" in classes(document, "#side-menu-shadow")
|
||||
assert "h-[100dvh]" in classes(document, "#attendee-room")
|
||||
assert "grid-rows-[auto_auto_minmax(0,1fr)]" in classes(document, "#attendee-room")
|
||||
assert "z-[60]" in classes(document, "#side-menu")
|
||||
assert "h-[40dvh]" in classes(document, "#focus-slot")
|
||||
assert Floki.find(document, "#focus-media img") != []
|
||||
assert Floki.find(document, "[data-focus-collapse]") != []
|
||||
assert Floki.find(document, "[data-focus-show]") != []
|
||||
assert document |> Floki.find("[data-focus-show]") |> Floki.text() =~ "Show presentation"
|
||||
assert "overflow-y-auto" in classes(document, "#chat-feed")
|
||||
assert Floki.attribute(document, "#chat-feed", "phx-hook") == ["RoomFeed"]
|
||||
assert Floki.attribute(document, "#post-form", "phx-hook") == ["PostForm"]
|
||||
assert "attendee-composer" in classes(document, "#post-form")
|
||||
assert Floki.find(document, "#room-topbar") != []
|
||||
assert Floki.find(document, "#room-composer") != []
|
||||
assert Floki.find(document, "#top-identity-button") == []
|
||||
assert Floki.find(document, "#composer-identity-button") != []
|
||||
|
||||
[close_command] = Floki.attribute(document, "#nicknamepicker", "data-close")
|
||||
|
||||
assert [["toggle", %{"to" => "#nickname-popup"} = options]] = Jason.decode!(close_command)
|
||||
assert options["display"] == "flex"
|
||||
assert [["hide", %{"to" => "#identity-menu"}]] = Jason.decode!(close_command)
|
||||
assert Floki.attribute(document, "#nicknamepicker", "phx-click") == []
|
||||
end
|
||||
|
||||
test "updates reaction controls when message reactions are enabled", %{conn: conn, user: user} do
|
||||
presentation_file = presentation_file_fixture(%{user: user}, [:event])
|
||||
|
||||
state =
|
||||
presentation_state_fixture(%{
|
||||
presentation_file: presentation_file,
|
||||
message_reaction_enabled: false
|
||||
})
|
||||
|
||||
post_fixture(%{event: presentation_file.event, user: user_fixture()})
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/e/#{presentation_file.event.code}")
|
||||
|
||||
refute html =~ "data-message-reaction-trigger"
|
||||
|
||||
send(view.pid, {:state_updated, %{state | message_reaction_enabled: true}})
|
||||
|
||||
assert render(view) =~ "data-message-reaction-trigger"
|
||||
end
|
||||
|
||||
defp classes(document, selector) do
|
||||
document
|
||||
|> Floki.attribute(selector, "class")
|
||||
|
||||
@@ -57,7 +57,7 @@ defmodule ClaperWeb.EventLiveTest do
|
||||
{:ok, _show_live, html} =
|
||||
live(conn, ~p"/e/#{presentation_file.event.code}")
|
||||
|
||||
assert html =~ "Be the first to react !"
|
||||
assert html =~ "Be the first to ask a question or share a thought."
|
||||
assert html =~ presentation_file.event.name
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,21 +4,58 @@ defmodule ClaperWeb.PostLiveTest do
|
||||
import Phoenix.LiveViewTest
|
||||
import Claper.{PresentationsFixtures, PostsFixtures}
|
||||
|
||||
alias Claper.Posts
|
||||
|
||||
defp create_event(params) do
|
||||
presentation_file = presentation_file_fixture(%{user: params.user}, [:event])
|
||||
presentation_state_fixture(%{presentation_file: presentation_file})
|
||||
post = post_fixture(%{user: params.user, event: presentation_file.event})
|
||||
|
||||
post =
|
||||
post_fixture(%{
|
||||
user: params.user,
|
||||
event: presentation_file.event,
|
||||
like_count: 1,
|
||||
love_count: 0,
|
||||
lol_count: 0
|
||||
})
|
||||
|
||||
params |> Map.put(:presentation_file, presentation_file) |> Map.put(:post, post)
|
||||
end
|
||||
|
||||
describe "Index" do
|
||||
setup [:register_and_log_in_user, :create_event]
|
||||
|
||||
test "list posts", %{conn: conn, presentation_file: presentation_file} do
|
||||
{:ok, _index_live, html} =
|
||||
test "list posts", %{conn: conn, post: post, presentation_file: presentation_file} do
|
||||
{:ok, index_live, html} =
|
||||
live(conn, ~p"/e/#{presentation_file.event.code}")
|
||||
|
||||
assert html =~ "some body"
|
||||
|
||||
document = Floki.parse_document!(html)
|
||||
|
||||
assert Floki.find(document, "[data-message-reaction-trigger]") == []
|
||||
assert Floki.find(document, "[data-message-reaction-menu]") == []
|
||||
assert [reaction_chip] = Floki.find(document, "[data-reaction-chip]")
|
||||
assert Floki.text(reaction_chip) =~ "1"
|
||||
assert Floki.attribute(reaction_chip, "disabled") != []
|
||||
assert reaction_chip |> Floki.attribute("class") |> List.first() =~ "text-gray-800"
|
||||
|
||||
render_click(index_live, "react", %{"type" => "👍", "post-id" => post.uuid})
|
||||
assert Posts.get_post!(post.uuid).like_count == 1
|
||||
end
|
||||
|
||||
test "allows reacting to another attendee's post", %{
|
||||
conn: conn,
|
||||
presentation_file: presentation_file
|
||||
} do
|
||||
post_fixture(%{event: presentation_file.event, like_count: 0})
|
||||
|
||||
{:ok, _index_live, html} = live(conn, ~p"/e/#{presentation_file.event.code}")
|
||||
|
||||
document = Floki.parse_document!(html)
|
||||
|
||||
assert [_trigger] = Floki.find(document, "[data-message-reaction-trigger]")
|
||||
assert [_menu] = Floki.find(document, "[data-message-reaction-menu]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user