Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_comments/web/comments_component.html.heex
<div>
<script>
(function() {
window.PhoenixKitHooks = window.PhoenixKitHooks || {};
if (window.PhoenixKitHooks.PhoenixKitCommentsAudioRecorder) return;
window.PhoenixKitHooks.PhoenixKitCommentsAudioRecorder = {
mounted() {
this.recording = false;
this.recorder = null;
this.stream = null;
this.chunks = [];
this._onToggle = () => {
if (this.recording) this.stop(); else this.start();
};
window.addEventListener("phx-kit-comments-audio-toggle", this._onToggle);
},
destroyed() {
window.removeEventListener("phx-kit-comments-audio-toggle", this._onToggle);
this.cleanup();
},
async start() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this.pushError("Microphone access is not supported by this browser.");
return;
}
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
this.pushError("Microphone permission denied.");
return;
}
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm";
try {
this.recorder = new MediaRecorder(this.stream, { mimeType });
} catch (err) {
this.cleanup();
this.pushError("Could not start recording: " + err.message);
return;
}
this.chunks = [];
this.recorder.addEventListener("dataavailable", (ev) => {
if (ev.data && ev.data.size > 0) this.chunks.push(ev.data);
});
this.recorder.addEventListener("stop", () => this.finalize(mimeType));
this.recorder.start();
this.recording = true;
this.pushEventTo(this.el, "audio_recording_started", {});
},
stop() {
if (this.recorder && this.recorder.state !== "inactive") {
this.recorder.stop();
}
},
finalize(mimeType) {
const blob = new Blob(this.chunks, { type: mimeType });
const ext = mimeType.indexOf("webm") >= 0 ? "webm" : "ogg";
const name = "voice-" + new Date().toISOString().replace(/[:.]/g, "-") + "." + ext;
const file = new File([blob], name, { type: mimeType });
const uploadName = this.el.dataset.uploadName || "attachment";
try {
this.upload(uploadName, [file]);
} catch (err) {
this.pushError("Failed to attach recording: " + err.message);
}
this.recording = false;
this.cleanup();
this.pushEventTo(this.el, "audio_recording_stopped", {});
},
cleanup() {
if (this.stream) {
this.stream.getTracks().forEach((t) => t.stop());
this.stream = null;
}
this.recorder = null;
this.chunks = [];
},
pushError(msg) {
this.pushEventTo(this.el, "audio_recording_error", { message: msg });
this.cleanup();
this.recording = false;
}
};
})();
</script>
<style>
/* Markdown-rendered comment bodies (.pk-comment-md): restore the
list / block styling Tailwind's preflight resets, without relying on
the @tailwindcss/typography (`prose`) plugin being present in the host.
Bold/italic already render via <strong>/<em>; lists, quotes, headings
and code need this. */
.pk-comment-md ul,
.pk-comment-md ol {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.pk-comment-md ul {
list-style: disc;
}
.pk-comment-md ul ul {
list-style: circle;
}
.pk-comment-md ol {
list-style: decimal;
}
.pk-comment-md li {
margin: 0.125rem 0;
}
.pk-comment-md p {
margin: 0.25rem 0;
}
.pk-comment-md a {
color: oklch(var(--p));
text-decoration: underline;
}
.pk-comment-md :is(h1, h2, h3, h4, h5, h6) {
font-weight: 600;
margin: 0.5rem 0 0.25rem;
}
.pk-comment-md blockquote {
border-left: 3px solid oklch(var(--bc) / 0.2);
padding-left: 0.75rem;
margin: 0.5rem 0;
opacity: 0.85;
}
/* Inline `code` — backticks render as <code>; without this it reads as
plain text since Tailwind's preflight strips the styling. */
.pk-comment-md code {
background: oklch(var(--bc) / 0.1);
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.pk-comment-md pre {
background: oklch(var(--bc) / 0.08);
overflow-x: auto;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
}
/* Fenced blocks already have the <pre> backdrop; don't double-style the
inner <code>. */
.pk-comment-md pre code {
background: none;
padding: 0;
font-size: inherit;
}
/* Mobile: centered modal with backdrop */
.pk-giphy-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.pk-giphy-picker {
width: min(24rem, calc(100vw - 2rem));
max-height: 70vh;
display: flex;
flex-direction: column;
}
.pk-giphy-picker-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
/* Desktop: inline dropdown, no backdrop */
@media (min-width: 640px) {
.pk-giphy-backdrop {
position: absolute;
inset: auto;
top: 100%;
left: 0;
background: none;
display: block;
z-index: 50;
margin-top: 0.5rem;
}
.pk-giphy-picker {
width: 24rem;
max-height: none;
}
.pk-giphy-picker-scroll {
max-height: 28rem;
}
}
</style>
<%= if @enabled do %>
<div class="card bg-base-100 shadow-xl">
<div class="card-body overflow-hidden">
<%!-- Header. `show_title` gates the whole line. When --%>
<%!-- `collapsible`, the line becomes a disclosure toggle for --%>
<%!-- the body below (chevron + aria-expanded/-controls); --%>
<%!-- otherwise it's the plain static heading. --%>
<%= if @show_title do %>
<%= if @collapsible do %>
<button
type="button"
phx-click="toggle_collapsed"
phx-target={@myself}
aria-expanded={to_string(not @collapsed?)}
aria-controls={"pk-comments-body-#{@id}"}
class="card-title text-xl sm:text-2xl mb-4 flex items-center justify-between w-full text-left gap-2"
>
<span class="flex items-center gap-2 min-w-0">
<.icon name="hero-chat-bubble-left-right" class="w-6 h-6 shrink-0" />
<span class="break-words">{@title} ({@comment_count})</span>
</span>
<.icon
name={if @collapsed?, do: "hero-chevron-right", else: "hero-chevron-down"}
class="w-5 h-5 shrink-0"
/>
</button>
<% else %>
<h2 class="card-title text-xl sm:text-2xl mb-4 flex-wrap">
<.icon name="hero-chat-bubble-left-right" class="w-6 h-6 shrink-0" />
<span class="break-words">{@title} ({@comment_count})</span>
</h2>
<% end %>
<% end %>
<%!-- Body — collapsed away when collapsible + collapsed. The --%>
<%!-- wrapper id is the header toggle's aria-controls target and --%>
<%!-- stays in the DOM (empty when collapsed) so the reference --%>
<%!-- is always valid. --%>
<div id={"pk-comments-body-#{@id}"}>
<%= unless @collapsible and @collapsed? do %>
<%!-- Top composer --%>
<%= if @composer_position in [:top, :both] do %>
<.new_comment_composer ctx={assigns} position={:top} />
<% end %>
<%!-- Comments List --%>
<%= if length(@comments) > 0 do %>
<div class="space-y-4">
<%= for comment <- @comments do %>
<.render_comment
comment={comment}
current_user={@current_user}
myself={@myself}
component_id={@id}
editing_uuid={@editing_uuid}
editing_content={@editing_content}
comment_decorations={@comment_decorations}
editing_decoration_uuid={@editing_decoration_uuid}
editing_decoration_value={@editing_decoration_value}
show_likes={@show_likes}
liked_comment_uuids={@liked_comment_uuids}
disliked_comment_uuids={@disliked_comment_uuids}
reply_to={@reply_to}
ctx={assigns}
/>
<% end %>
</div>
<% else %>
<div class="text-center py-8 text-base-content/60">
<.icon name="hero-chat-bubble-left" class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>{gettext("No comments yet. Be the first to comment!")}</p>
</div>
<% end %>
<%!-- Bottom composer (off by default) --%>
<%= if @composer_position in [:bottom, :both] do %>
<.new_comment_composer ctx={assigns} position={:bottom} />
<% end %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>