Current section

Files

Jump to
phoenix_kit_comments lib phoenix_kit_comments web settings.html.heex
Raw

lib/phoenix_kit_comments/web/settings.html.heex

<div class="container flex flex-col mx-auto px-4 py-6 overflow-x-hidden">
<.admin_page_header
back={PhoenixKit.Utils.Routes.path("/admin/settings")}
title="Comments Settings"
subtitle="Configure the standalone comments module"
/>
<%!-- Settings Form --%>
<div class="max-w-4xl w-full mx-auto space-y-8">
<.form for={%{}} phx-submit="save">
<%!-- Module Status --%>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body overflow-hidden">
<h2 class="card-title text-xl sm:text-2xl mb-4 flex-wrap">
<.icon name="hero-cog-6-tooth" class="w-6 h-6" /> Module Status
</h2>
<div class="form-control">
<label class="cursor-pointer flex flex-col sm:flex-row sm:items-center gap-3 p-2">
<span class="flex-1 min-w-0">
<span class="font-semibold text-lg">Enable Comments</span>
<div class="text-sm text-base-content/70 mt-1">
Enable the standalone comments system across all content types
</div>
</span>
<input type="hidden" name="settings[comments_enabled]" value="false" />
<input
type="checkbox"
name="settings[comments_enabled]"
value="true"
class="toggle toggle-primary shrink-0"
checked={@comments_enabled == "true"}
/>
</label>
</div>
</div>
</div>
<%!-- Moderation --%>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body overflow-hidden">
<h2 class="card-title text-xl sm:text-2xl mb-4 flex-wrap">
<.icon name="hero-shield-check" class="w-6 h-6" /> Moderation
</h2>
<div class="form-control">
<label class="cursor-pointer flex flex-col sm:flex-row sm:items-center gap-3 p-2">
<span class="flex-1 min-w-0">
<span class="font-semibold text-lg">Require Moderation</span>
<div class="text-sm text-base-content/70 mt-1">
New comments must be approved before appearing publicly
</div>
</span>
<input type="hidden" name="settings[comments_moderation]" value="false" />
<input
type="checkbox"
name="settings[comments_moderation]"
value="true"
class="toggle toggle-warning shrink-0"
checked={@comments_moderation == "true"}
/>
</label>
</div>
</div>
</div>
<%!-- Limits --%>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body overflow-hidden">
<h2 class="card-title text-xl sm:text-2xl mb-4 flex-wrap">
<.icon name="hero-adjustments-horizontal" class="w-6 h-6" /> Limits
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Max Thread Depth</span>
</label>
<input
type="number"
name="settings[comments_max_depth]"
value={@comments_max_depth}
min="1"
max="50"
class="input input-bordered"
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
Maximum nesting level for reply threads (default: 10)
</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Max Comment Length</span>
</label>
<input
type="number"
name="settings[comments_max_length]"
value={@comments_max_length}
min="100"
max="100000"
class="input input-bordered"
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
Maximum characters per comment (default: 10,000)
</span>
</label>
</div>
</div>
</div>
</div>
<%!-- Giphy Integration --%>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body overflow-hidden">
<h2 class="card-title text-xl sm:text-2xl mb-4 flex-wrap">
<.icon name="hero-film" class="w-6 h-6" /> Giphy Integration
</h2>
<div class="form-control mb-4">
<label class="cursor-pointer flex flex-col sm:flex-row sm:items-center gap-3 p-2">
<span class="flex-1 min-w-0">
<span class="font-semibold text-lg">Enable Giphy Picker</span>
<div class="text-sm text-base-content/70 mt-1">
Show a GIF picker in the comment form. Requires a valid API key below.
</div>
</span>
<input type="hidden" name="settings[comments_giphy_enabled]" value="false" />
<input
type="checkbox"
name="settings[comments_giphy_enabled]"
value="true"
class="toggle toggle-primary shrink-0"
checked={@comments_giphy_enabled == "true"}
/>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Giphy API Key</span>
</label>
<input
type="password"
name="settings[comments_giphy_api_key]"
value={@comments_giphy_api_key}
autocomplete="off"
placeholder="Paste your Giphy API key"
class="input input-bordered"
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
Stored in the database. Leave blank to disable.
</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Content Rating</span>
</label>
<select
name="settings[comments_giphy_rating]"
class="select select-bordered"
>
<option value="g" selected={@comments_giphy_rating == "g"}>G</option>
<option value="pg" selected={@comments_giphy_rating == "pg"}>PG</option>
<option value="pg-13" selected={@comments_giphy_rating == "pg-13"}>PG-13</option>
<option value="r" selected={@comments_giphy_rating == "r"}>R</option>
</select>
<label class="label">
<span class="label-text-alt text-base-content/60">
Maximum rating returned by Giphy search (default: G)
</span>
</label>
</div>
</div>
</div>
</div>
<%!-- Attachments --%>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body overflow-hidden">
<h2 class="card-title text-xl sm:text-2xl mb-4 flex-wrap">
<.icon name="hero-paper-clip" class="w-6 h-6" /> Attachments
</h2>
<div class="form-control mb-4">
<label class="cursor-pointer flex flex-col sm:flex-row sm:items-center gap-3 p-2">
<span class="flex-1 min-w-0">
<span class="font-semibold text-lg">Enable Comment Attachments</span>
<div class="text-sm text-base-content/70 mt-1">
Allow users to attach images, video, audio, or files to comments.
Voice messages can also be recorded in-browser. Uses the global
storage backend configured under Settings → Storage.
</div>
</span>
<input type="hidden" name="settings[comments_attachments_enabled]" value="false" />
<input
type="checkbox"
name="settings[comments_attachments_enabled]"
value="true"
class="toggle toggle-primary shrink-0"
checked={@comments_attachments_enabled == "true"}
/>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Max attachments per comment</span>
</label>
<input
type="number"
name="settings[comments_max_attachments]"
value={@comments_max_attachments}
min="1"
max="10"
class="input input-bordered"
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
Per-comment cap (1–10). Default: 4
</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Max file size (MB)</span>
</label>
<input
type="number"
name="settings[comments_attachment_max_size_mb]"
value={@comments_attachment_max_size_mb}
min="1"
max="500"
class="input input-bordered"
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
Clamped against the global storage cap. Default: 20
</span>
</label>
</div>
</div>
</div>
</div>
<%!-- Action Buttons --%>
<div class="flex flex-col-reverse sm:flex-row gap-3 sm:gap-4 sm:justify-end">
<button
type="button"
phx-click="reset_defaults"
class="btn btn-ghost btn-outline w-full sm:w-auto"
>
Reset to Defaults
</button>
<button
type="submit"
class="btn btn-primary w-full sm:w-auto"
disabled={@saving}
>
<%= if @saving do %>
<span class="loading loading-spinner loading-sm"></span> Saving...
<% else %>
Save Settings
<% end %>
</button>
</div>
</.form>
<%!-- Resource Link Patterns (outside main form to avoid nested forms) --%>
<script>
(function() {
window.PhoenixKitHooks = window.PhoenixKitHooks || {};
if (window.PhoenixKitHooks.InsertAtCursor) return;
window.PhoenixKitHooks.InsertAtCursor = {
mounted() {
this._lastInput = null;
this._lastCursorPos = 0;
this._abortController = null;
this.setup();
},
updated() { this.setup(); },
destroyed() {
if (this._abortController) this._abortController.abort();
},
colorBadges() {
var container = this.el;
var input = this._lastInput;
var val = input ? input.value : "";
container.querySelectorAll("[data-field]").forEach(function(badge) {
var used = val && val.indexOf(badge.dataset.field) !== -1;
badge.classList.remove("badge-success", "badge-ghost");
badge.classList.add(used ? "badge-success" : "badge-ghost");
});
},
setup() {
if (this._abortController) this._abortController.abort();
this._abortController = new AbortController();
var signal = this._abortController.signal;
var self = this;
var container = this.el;
var trackCursor = function(input) {
self._lastInput = input;
self._lastCursorPos = input.selectionStart;
self.colorBadges();
};
container.querySelectorAll("textarea, input[type='text']").forEach(function(input) {
input.addEventListener("focus", function() { trackCursor(input); }, { signal: signal });
input.addEventListener("keyup", function() { trackCursor(input); }, { signal: signal });
input.addEventListener("click", function() { trackCursor(input); }, { signal: signal });
input.addEventListener("input", function() { self.colorBadges(); }, { signal: signal });
});
self.colorBadges();
container.querySelectorAll("[data-field]").forEach(function(badge) {
badge.style.cursor = "pointer";
badge.addEventListener("click", function(e) {
e.preventDefault();
var input = self._lastInput || container.querySelector("textarea, input[type='text']");
if (!input) return;
var val = badge.dataset.field;
var pos = self._lastCursorPos || 0;
input.value = input.value.slice(0, pos) + val + input.value.slice(pos);
var newPos = pos + val.length;
self._lastCursorPos = newPos;
input.selectionStart = input.selectionEnd = newPos;
input.focus();
input.dispatchEvent(new Event("input", { bubbles: true }));
}, { signal: signal });
});
}
};
})();
</script>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body overflow-hidden">
<h2 class="card-title text-xl sm:text-2xl mb-2 flex-wrap">
<.icon name="hero-link" class="w-6 h-6" /> Resource Link Patterns
</h2>
<p class="text-sm text-base-content/60 mb-4 break-words">
Configure URL patterns for resource types so comments link to their source in the admin page.
Use <code class="kbd kbd-sm break-all">:uuid</code> for resource ID,
<code class="kbd kbd-sm break-all">:metadata.KEY</code> for values from comment metadata,
and <code class="kbd kbd-sm break-all">:prefix</code> to include the site URL prefix.
</p>
<p class="text-xs text-base-content/40 mb-4 break-words">
Example: <code class="break-all">/products/:uuid</code>
or <code class="break-all">:prefix/admin/shop/:uuid</code>
</p>
<%!-- Existing patterns --%>
<%= if map_size(@resource_paths) > 0 do %>
<%!-- Desktop: table view --%>
<div class="hidden md:block mb-4">
<table class="table table-sm table-fixed w-full">
<thead>
<tr>
<th>Resource Type</th>
<th>Path Template</th>
<th>Display Template</th>
<th class="w-20"></th>
</tr>
</thead>
<tbody>
<%= for {resource_type, config} <- Enum.sort(@resource_paths) do %>
<% display_path = extract_path(config) %>
<% display_title = extract_title(config) %>
<%= if @editing_resource_type == resource_type do %>
<tr>
<td>
<span class="badge badge-ghost badge-sm">{resource_type}</span>
</td>
<td colspan="3">
<div
id={"edit-path-#{resource_type}"}
phx-hook="InsertAtCursor"
>
<.form
for={%{}}
phx-submit="save_resource_path"
phx-change="live_edit_path"
class="space-y-2"
>
<div>
<label class="label py-0">
<span class="label-text text-xs font-semibold">Path</span>
</label>
<textarea
name="resource_path[path_template]"
class="textarea textarea-bordered textarea-sm w-full min-h-[4rem]"
rows="2"
required
><%= @editing_path_value %></textarea>
</div>
<div>
<label class="label py-0">
<span class="label-text text-xs font-semibold">Display title</span>
</label>
<input
type="text"
name="resource_path[title_template]"
value={@editing_title_value}
placeholder="e.g. :metadata.slug - Ch. :metadata.chapter"
class="input input-bordered input-sm w-full"
/>
</div>
<%= if keys = Map.get(@metadata_keys_by_type, resource_type) do %>
<div class="text-xs text-base-content/60 flex flex-wrap items-center gap-1">
<span class="font-semibold">Available fields:</span>
<%= for key <- keys do %>
<span
class="badge badge-ghost badge-xs font-mono gap-0.5"
data-field={":metadata.#{key}"}
>
:metadata.{key}
</span>
<% end %>
</div>
<% end %>
<div class="flex justify-end gap-2">
<button
type="button"
phx-click="cancel_edit_resource_path"
class="btn btn-ghost btn-xs"
>
Cancel
</button>
<button type="submit" class="btn btn-primary btn-xs">
Save
</button>
</div>
</.form>
</div>
</td>
</tr>
<% else %>
<tr>
<td>
<span class="badge badge-ghost badge-sm">{resource_type}</span>
</td>
<td class="max-w-[280px]">
<code class="text-sm break-all">{display_path}</code>
</td>
<td class="max-w-[200px]">
<%= if display_title != "" do %>
<code class="text-sm break-all">{display_title}</code>
<% else %>
<span class="text-base-content/40 text-xs">UUID (default)</span>
<% end %>
</td>
<td>
<div class="flex gap-1">
<button
type="button"
phx-click="edit_resource_path"
phx-value-type={resource_type}
class="btn btn-ghost btn-xs"
>
<.icon name="hero-pencil-square" class="w-4 h-4" />
</button>
<button
type="button"
phx-click="remove_resource_path"
phx-value-type={resource_type}
class="btn btn-ghost btn-xs text-error"
data-confirm={"Remove path pattern for \"#{resource_type}\"?"}
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
<%!-- Mobile: card view --%>
<div class="md:hidden space-y-3 mb-4">
<%= for {resource_type, config} <- Enum.sort(@resource_paths) do %>
<% display_path = extract_path(config) %>
<% display_title = extract_title(config) %>
<%= if @editing_resource_type == resource_type do %>
<div
id={"edit-path-mobile-#{resource_type}"}
phx-hook="InsertAtCursor"
class="bg-base-200 rounded-lg p-3"
>
<div class="flex items-center justify-between mb-2">
<span class="badge badge-ghost badge-sm">{resource_type}</span>
</div>
<.form
for={%{}}
phx-submit="save_resource_path"
phx-change="live_edit_path"
class="space-y-2"
>
<div>
<label class="label py-0">
<span class="label-text text-xs font-semibold">Path</span>
</label>
<textarea
name="resource_path[path_template]"
class="textarea textarea-bordered textarea-sm w-full min-h-[4rem]"
rows="2"
required
><%= @editing_path_value %></textarea>
</div>
<div>
<label class="label py-0">
<span class="label-text text-xs font-semibold">Display title</span>
</label>
<input
type="text"
name="resource_path[title_template]"
value={@editing_title_value}
placeholder="e.g. :metadata.slug"
class="input input-bordered input-sm w-full"
/>
</div>
<%= if keys = Map.get(@metadata_keys_by_type, resource_type) do %>
<div class="text-xs text-base-content/60 flex flex-wrap items-center gap-1">
<span class="font-semibold">Available fields:</span>
<%= for key <- keys do %>
<span
class="badge badge-ghost badge-xs font-mono gap-0.5"
data-field={":metadata.#{key}"}
>
:metadata.{key}
</span>
<% end %>
</div>
<% end %>
<div class="flex justify-end gap-2">
<button
type="button"
phx-click="cancel_edit_resource_path"
class="btn btn-ghost btn-xs"
>
Cancel
</button>
<button type="submit" class="btn btn-primary btn-xs">
Save
</button>
</div>
</.form>
</div>
<% else %>
<div class="bg-base-200 rounded-lg p-3 space-y-1">
<div class="flex items-center justify-between">
<span class="badge badge-ghost badge-sm">{resource_type}</span>
<div class="flex gap-1">
<button
type="button"
phx-click="edit_resource_path"
phx-value-type={resource_type}
class="btn btn-ghost btn-xs"
>
<.icon name="hero-pencil-square" class="w-4 h-4" />
</button>
<button
type="button"
phx-click="remove_resource_path"
phx-value-type={resource_type}
class="btn btn-ghost btn-xs text-error"
data-confirm={"Remove path pattern for \"#{resource_type}\"?"}
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</div>
<div>
<span class="text-xs text-base-content/50">Path:</span>
<code class="text-xs break-all">{display_path}</code>
</div>
<%= if display_title != "" do %>
<div>
<span class="text-xs text-base-content/50">Display:</span>
<code class="text-xs break-all">{display_title}</code>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% else %>
<div class="text-center py-4 text-base-content/50 text-sm mb-4">
No resource link patterns configured yet.
</div>
<% end %>
<%!-- Unconfigured types from DB --%>
<%= if @unconfigured_types != [] do %>
<div class="mb-4">
<h3 class="text-sm font-semibold text-warning mb-2">
<.icon name="hero-exclamation-triangle" class="w-4 h-4 inline" />
Resource types without link patterns
</h3>
<div class="space-y-3">
<%= for resource_type <- @unconfigured_types do %>
<div
id={"draft-path-#{resource_type}"}
phx-hook="InsertAtCursor"
class="bg-base-200 rounded-lg p-3"
>
<.form
for={%{}}
phx-submit="add_resource_path"
phx-change="live_draft_path"
class="space-y-2"
>
<input
type="hidden"
name="resource_path[resource_type]"
value={resource_type}
/>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="badge badge-warning badge-sm">{resource_type}</span>
<span class="text-xs text-base-content/50">
{Map.get(@counts_by_type, resource_type, 0)} comments
</span>
</div>
</div>
<div class="form-control">
<label class="label py-0">
<span class="label-text text-xs font-semibold">Path</span>
</label>
<input
type="text"
name="resource_path[path_template]"
value={Map.get(@draft_paths, resource_type, "")}
placeholder={"e.g. /#{resource_type}/:uuid"}
class="input input-bordered input-sm w-full"
required
/>
</div>
<div class="form-control">
<label class="label py-0">
<span class="label-text text-xs font-semibold">Display title</span>
</label>
<input
type="text"
name="resource_path[title_template]"
value={Map.get(@draft_titles, resource_type, "")}
placeholder="e.g. :metadata.slug"
class="input input-bordered input-sm w-full"
/>
</div>
<%= if keys = Map.get(@metadata_keys_by_type, resource_type) do %>
<div class="text-xs text-base-content/60 flex flex-wrap items-center gap-1">
<span class="font-semibold">Available fields:</span>
<%= for key <- keys do %>
<span
class="badge badge-ghost badge-xs font-mono gap-0.5"
data-field={":metadata.#{key}"}
>
:metadata.{key}
</span>
<% end %>
</div>
<% end %>
<div class="flex justify-end">
<button type="submit" class="btn btn-sm btn-outline btn-warning">
<.icon name="hero-plus" class="w-4 h-4" /> Add
</button>
</div>
</.form>
</div>
<% end %>
</div>
</div>
<div class="divider text-xs text-base-content/40">or add manually</div>
<% end %>
<%!-- Add new pattern manually --%>
<div class="bg-base-200 rounded-lg p-3">
<.form for={%{}} phx-submit="add_resource_path" class="space-y-2">
<div class="form-control">
<label class="label py-0">
<span class="label-text text-xs font-semibold">Resource Type</span>
</label>
<input
type="text"
name="resource_path[resource_type]"
placeholder="e.g. shoes"
class="input input-bordered input-sm w-full"
required
/>
</div>
<div class="form-control">
<label class="label py-0">
<span class="label-text text-xs font-semibold">Path</span>
</label>
<input
type="text"
name="resource_path[path_template]"
placeholder="e.g. /order/shoes/:uuid"
class="input input-bordered input-sm w-full"
required
/>
</div>
<div class="form-control">
<label class="label py-0">
<span class="label-text text-xs font-semibold">Display title</span>
</label>
<input
type="text"
name="resource_path[title_template]"
placeholder="e.g. :metadata.slug - Ch. :metadata.chapter"
class="input input-bordered input-sm w-full"
/>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-sm btn-outline btn-primary">
<.icon name="hero-plus" class="w-4 h-4" /> Add
</button>
</div>
</.form>
</div>
</div>
</div>
</div>
</div>