Revamp attendee room UI and add reactions

This commit is contained in:
Alex Lion
2026-07-16 17:32:08 +02:00
parent ba6373319e
commit faccbfa5e7
26 changed files with 5911 additions and 2807 deletions

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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"

View File

@@ -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 %>

View File

@@ -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>

View File

@@ -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

View File

@@ -213,6 +213,7 @@
module={ClaperWeb.EventLive.EmbedIframeComponent}
provider={@current_embed.provider}
content={@current_embed.content}
title={@current_embed.title}
/>
</div>
<% end %>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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")}&nbsp;↓
</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>

View File

@@ -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

View 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

View File

@@ -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")

View File

@@ -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

View File

@@ -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