Packages
phoenix_kit
1.7.176
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
221 Versions
Jump to
Current section
221 Versions
Compare versions
14
files changed
+489
additions
-14
deletions
unknownCHANGELOG.md
File is too large to be displayed (100 KB limit).
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}. |
| 2 2 | {<<"name">>,<<"phoenix_kit">>}. |
| 3 | - {<<"version">>,<<"1.7.175">>}. |
| 3 | + {<<"version">>,<<"1.7.176">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105, |
| 6 6 | 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105, |
| @@ -208,6 +208,7 @@ | |
| 208 208 | <<"lib/phoenix_kit_web/components/core/markdown_content.ex">>, |
| 209 209 | <<"lib/phoenix_kit_web/components/core/textarea.ex">>, |
| 210 210 | <<"lib/phoenix_kit_web/components/core/table_row_menu.ex">>, |
| 211 | + <<"lib/phoenix_kit_web/components/core/resource_link.ex">>, |
| 211 212 | <<"lib/phoenix_kit_web/components/core/bulk_select.ex">>, |
| 212 213 | <<"lib/phoenix_kit_web/components/core/aws_region_select.ex">>, |
| 213 214 | <<"lib/phoenix_kit_web/components/core/integration_picker.ex">>, |
| @@ -353,7 +354,8 @@ | |
| 353 354 | <<"lib/phoenix_kit/integrations/oauth.ex">>, |
| 354 355 | <<"lib/phoenix_kit/supervisor.ex">>,<<"lib/phoenix_kit/audit_log.ex">>, |
| 355 356 | <<"lib/phoenix_kit/theme_config.ex">>,<<"lib/phoenix_kit/audit_log">>, |
| 356 | - <<"lib/phoenix_kit/audit_log/entry.ex">>,<<"lib/phoenix_kit/migration.ex">>, |
| 357 | + <<"lib/phoenix_kit/audit_log/entry.ex">>, |
| 358 | + <<"lib/phoenix_kit/resource_links.ex">>,<<"lib/phoenix_kit/migration.ex">>, |
| 357 359 | <<"lib/phoenix_kit/notifications">>, |
| 358 360 | <<"lib/phoenix_kit/notifications/notifications.ex">>, |
| 359 361 | <<"lib/phoenix_kit/notifications/types.ex">>, |
| @@ -0,0 +1,286 @@ | |
| 1 | + defmodule PhoenixKit.ResourceLinks do |
| 2 | + @moduledoc """ |
| 3 | + Resolves `(resource_type, resource_uuid)` pairs into navigable deep-links to |
| 4 | + the underlying resource — the record an action happened on or a comment is |
| 5 | + attached to. |
| 6 | + |
| 7 | + Two-tier resolution, tried in order per `resource_type`: |
| 8 | + |
| 9 | + 1. **Handler modules** — a module registered for the type that exports |
| 10 | + `resolve_comment_resources/1`, returning `%{uuid => %{title, path, thumb_url?}}` |
| 11 | + with a **raw** phoenix_kit path (`Routes.path/1` is applied once at render |
| 12 | + time — pre-applying it would double-prefix under a non-root `url_prefix`). |
| 13 | + Auto-registered when the module is loaded: `"post" => PhoenixKitPosts`, |
| 14 | + `"file" => PhoenixKit.Annotations`, `"user" => PhoenixKit.Users.CommentResources`. |
| 15 | + Hosts add more via `config :phoenix_kit, :comment_resource_handlers`. |
| 16 | + |
| 17 | + 2. **String path templates** — the `comment_resource_paths` setting, a no-code |
| 18 | + JSON map (`%{"shoes" => "/order/shoes/:uuid"}`) with `:uuid` / `:prefix` / |
| 19 | + `:metadata.<key>` placeholders. |
| 20 | + |
| 21 | + Shared by the Comments moderation admin (`PhoenixKitComments` delegates here) |
| 22 | + and the Activity feed. The handler contract and setting name keep the |
| 23 | + historical `comment_*` naming, but the mechanism is resource-generic — a |
| 24 | + resource that deep-links in comments deep-links in Activity with no extra |
| 25 | + config. |
| 26 | + |
| 27 | + ## Items |
| 28 | + |
| 29 | + `resolve/1` accepts any list of maps/structs exposing `:resource_type`, |
| 30 | + `:resource_uuid`, and (optionally) `:metadata` — both `PhoenixKit.Activity.Entry` |
| 31 | + and comment structs qualify. Items missing a type or uuid are skipped. |
| 32 | + |
| 33 | + Returns a map keyed by `{resource_type, resource_uuid}`, each value a map: |
| 34 | + |
| 35 | + %{title: String.t(), full_title: String.t(), path: String.t(), |
| 36 | + prefixed: boolean(), thumb_url: String.t() (optional)} |
| 37 | + |
| 38 | + `prefixed: true` means `path` is a raw phoenix_kit route — pass it through |
| 39 | + `url/1` (→ `Routes.path/1`) and SPA-navigate. `prefixed: false` comes from a |
| 40 | + host template and should render as a plain `href`. |
| 41 | + """ |
| 42 | + |
| 43 | + require Logger |
| 44 | + |
| 45 | + alias PhoenixKit.Settings |
| 46 | + alias PhoenixKit.Utils.Routes |
| 47 | + |
| 48 | + @metadata_max_display_length 15 |
| 49 | + |
| 50 | + @doc """ |
| 51 | + Resolves resource context (title + path) for a list of items. |
| 52 | + |
| 53 | + See the module doc for the item and return shapes. |
| 54 | + """ |
| 55 | + def resolve(items) do |
| 56 | + items |
| 57 | + |> Enum.filter(&(present?(field(&1, :resource_type)) and present?(field(&1, :resource_uuid)))) |
| 58 | + |> Enum.group_by(&field(&1, :resource_type)) |
| 59 | + |> Enum.reduce(%{}, fn {resource_type, type_items}, acc -> |
| 60 | + resolved = resolve_for_type(resource_type, type_items) |
| 61 | + |
| 62 | + Enum.reduce(resolved, acc, fn {id, info}, inner -> |
| 63 | + Map.put(inner, {resource_type, id}, info) |
| 64 | + end) |
| 65 | + end) |
| 66 | + end |
| 67 | + |
| 68 | + @doc """ |
| 69 | + Looks up a resolved info map for a single `(resource_type, resource_uuid)` pair. |
| 70 | + |
| 71 | + Returns `nil` when the pair was not resolvable. |
| 72 | + """ |
| 73 | + def info_for(context, resource_type, resource_uuid) when is_map(context) do |
| 74 | + Map.get(context, {resource_type, resource_uuid}) |
| 75 | + end |
| 76 | + |
| 77 | + @doc """ |
| 78 | + Final navigable path for a resolved info map. |
| 79 | + |
| 80 | + Applies `Routes.path/1` (URL prefix + locale) to raw phoenix_kit paths |
| 81 | + (`prefixed: true`); returns host-template paths (`prefixed: false`) verbatim. |
| 82 | + """ |
| 83 | + def url(%{path: path} = info) do |
| 84 | + if info[:prefixed], do: Routes.path(path), else: path |
| 85 | + end |
| 86 | + |
| 87 | + @doc """ |
| 88 | + Gets configured resource path templates (path + optional display title). |
| 89 | + |
| 90 | + Returns a map of `resource_type => config`, where config is either a plain |
| 91 | + string (legacy path-only format) or a map with `"path"` and optional `"title"` |
| 92 | + keys. |
| 93 | + |
| 94 | + %{"shoes" => "/order/shoes/:uuid"} |
| 95 | + %{"shoes" => %{"path" => "/order/shoes/:uuid", "title" => ":metadata.name"}} |
| 96 | + """ |
| 97 | + def get_resource_path_templates do |
| 98 | + Settings.get_json_setting("comment_resource_paths", %{}) |
| 99 | + rescue |
| 100 | + e -> |
| 101 | + Logger.warning("Failed to load resource path templates: #{inspect(e)}") |
| 102 | + %{} |
| 103 | + end |
| 104 | + |
| 105 | + @doc """ |
| 106 | + Updates resource path templates for resource types. |
| 107 | + |
| 108 | + Accepts both legacy string values and new map values with `"path"` and |
| 109 | + `"title"` keys. |
| 110 | + """ |
| 111 | + def update_resource_path_templates(templates) when is_map(templates) do |
| 112 | + Settings.update_json_setting("comment_resource_paths", templates) |
| 113 | + end |
| 114 | + |
| 115 | + @doc """ |
| 116 | + The registered `resource_type => handler_module` map. |
| 117 | + |
| 118 | + Merges the auto-registered defaults (loaded post/file/user modules) with the |
| 119 | + host's `config :phoenix_kit, :comment_resource_handlers` overrides. Exposed so |
| 120 | + other consumers (e.g. the comments notification-callback dispatch) resolve a |
| 121 | + resource type against the same registry the deep-links use. |
| 122 | + """ |
| 123 | + def handlers do |
| 124 | + configured = Application.get_env(:phoenix_kit, :comment_resource_handlers, %{}) |
| 125 | + Map.merge(default_resource_handlers(), configured) |
| 126 | + end |
| 127 | + |
| 128 | + # ── Resolution ────────────────────────────────────────────────────── |
| 129 | + |
| 130 | + defp default_resource_handlers do |
| 131 | + handlers = %{} |
| 132 | + |
| 133 | + handlers = |
| 134 | + if Code.ensure_loaded?(PhoenixKitPosts), |
| 135 | + do: Map.put(handlers, "post", PhoenixKitPosts), |
| 136 | + else: handlers |
| 137 | + |
| 138 | + # File resources (incl. Etcher annotation discussions) resolve to the file's |
| 139 | + # media page via phoenix_kit core's Annotations context. |
| 140 | + handlers = |
| 141 | + if Code.ensure_loaded?(PhoenixKit.Annotations), |
| 142 | + do: Map.put(handlers, "file", PhoenixKit.Annotations), |
| 143 | + else: handlers |
| 144 | + |
| 145 | + # User resources resolve to the user's admin detail page (with avatar) via |
| 146 | + # phoenix_kit core's Users context. |
| 147 | + if Code.ensure_loaded?(PhoenixKit.Users.CommentResources), |
| 148 | + do: Map.put(handlers, "user", PhoenixKit.Users.CommentResources), |
| 149 | + else: handlers |
| 150 | + end |
| 151 | + |
| 152 | + defp resolve_for_type(resource_type, items) do |
| 153 | + resource_uuids = items |> Enum.map(&field(&1, :resource_uuid)) |> Enum.uniq() |
| 154 | + |
| 155 | + case resolve_via_handler(resource_type, resource_uuids) do |
| 156 | + result when map_size(result) > 0 -> |
| 157 | + Map.new(result, fn {id, info} -> {id, Map.put(info, :prefixed, true)} end) |
| 158 | + |
| 159 | + _ -> |
| 160 | + resolve_via_path_template(resource_type, items) |
| 161 | + end |
| 162 | + rescue |
| 163 | + e -> |
| 164 | + Logger.warning("Resource link resolver error: #{inspect(e)}") |
| 165 | + %{} |
| 166 | + end |
| 167 | + |
| 168 | + defp resolve_via_handler(resource_type, resource_uuids) do |
| 169 | + case Map.get(handlers(), resource_type) do |
| 170 | + nil -> |
| 171 | + %{} |
| 172 | + |
| 173 | + mod -> |
| 174 | + if Code.ensure_loaded?(mod) and function_exported?(mod, :resolve_comment_resources, 1) do |
| 175 | + mod.resolve_comment_resources(resource_uuids) |
| 176 | + else |
| 177 | + %{} |
| 178 | + end |
| 179 | + end |
| 180 | + end |
| 181 | + |
| 182 | + defp resolve_via_path_template(resource_type, items) do |
| 183 | + templates = get_resource_path_templates() |
| 184 | + |
| 185 | + case Map.get(templates, resource_type) do |
| 186 | + nil -> |
| 187 | + %{} |
| 188 | + |
| 189 | + config -> |
| 190 | + path_template = path_from_config(config) |
| 191 | + title_template = title_from_config(config) |
| 192 | + |
| 193 | + Map.new(items, fn item -> |
| 194 | + metadata = field(item, :metadata) || %{} |
| 195 | + uuid = field(item, :resource_uuid) |
| 196 | + path = apply_path_template(path_template, uuid, metadata) |
| 197 | + title = resolve_title(title_template, resource_type, uuid, metadata) |
| 198 | + full_title = resolve_full_title(title_template, resource_type, uuid, metadata) |
| 199 | + |
| 200 | + {uuid, %{title: title, full_title: full_title, path: path, prefixed: false}} |
| 201 | + end) |
| 202 | + end |
| 203 | + end |
| 204 | + |
| 205 | + defp resolve_title(nil, resource_type, uuid, _metadata) do |
| 206 | + short_id = uuid |> to_string() |> String.slice(0..7) |
| 207 | + "#{resource_type} #{short_id}..." |
| 208 | + end |
| 209 | + |
| 210 | + defp resolve_title(title_template, _resource_type, uuid, metadata) do |
| 211 | + apply_title_template(title_template, uuid, metadata) |
| 212 | + end |
| 213 | + |
| 214 | + defp resolve_full_title(nil, resource_type, uuid, _metadata) do |
| 215 | + "#{resource_type} #{uuid}" |
| 216 | + end |
| 217 | + |
| 218 | + defp resolve_full_title(title_template, _resource_type, uuid, metadata) do |
| 219 | + title_template |
| 220 | + |> replace_metadata_placeholders(metadata) |
| 221 | + |> String.replace(":uuid", to_string(uuid)) |
| 222 | + end |
| 223 | + |
| 224 | + defp path_from_config(config) when is_binary(config), do: config |
| 225 | + defp path_from_config(%{"path" => path}), do: path |
| 226 | + defp path_from_config(_), do: "" |
| 227 | + |
| 228 | + defp title_from_config(config) when is_binary(config), do: nil |
| 229 | + defp title_from_config(%{"title" => ""}), do: nil |
| 230 | + defp title_from_config(%{"title" => title}), do: title |
| 231 | + defp title_from_config(_), do: nil |
| 232 | + |
| 233 | + defp apply_path_template(template, resource_uuid, metadata) do |
| 234 | + template |
| 235 | + |> replace_metadata_url_placeholders(metadata) |
| 236 | + |> String.replace(":prefix", prefix_value()) |
| 237 | + |> String.replace(":uuid", url_encode(to_string(resource_uuid))) |
| 238 | + end |
| 239 | + |
| 240 | + defp apply_title_template(template, resource_uuid, metadata) do |
| 241 | + template |
| 242 | + |> replace_metadata_truncated(metadata) |
| 243 | + |> String.replace(":uuid", truncate_value(to_string(resource_uuid))) |
| 244 | + end |
| 245 | + |
| 246 | + defp prefix_value do |
| 247 | + prefix = Routes.url_prefix() |
| 248 | + if prefix == "/", do: "", else: prefix |
| 249 | + end |
| 250 | + |
| 251 | + defp replace_metadata_placeholders(template, metadata) do |
| 252 | + Regex.replace(~r/:metadata\.(\w+)/, template, fn _match, key -> |
| 253 | + metadata |> Map.get(key, "") |> to_string() |
| 254 | + end) |
| 255 | + end |
| 256 | + |
| 257 | + defp replace_metadata_url_placeholders(template, metadata) do |
| 258 | + Regex.replace(~r/:metadata\.(\w+)/, template, fn _match, key -> |
| 259 | + metadata |> Map.get(key, "") |> to_string() |> url_encode() |
| 260 | + end) |
| 261 | + end |
| 262 | + |
| 263 | + defp replace_metadata_truncated(template, metadata) do |
| 264 | + Regex.replace(~r/:metadata\.(\w+)/, template, fn _match, key -> |
| 265 | + metadata |> Map.get(key, "") |> to_string() |> truncate_value() |
| 266 | + end) |
| 267 | + end |
| 268 | + |
| 269 | + defp url_encode(value), do: URI.encode(value, &URI.char_unreserved?/1) |
| 270 | + |
| 271 | + defp truncate_value(value) do |
| 272 | + if String.length(value) <= @metadata_max_display_length do |
| 273 | + value |
| 274 | + else |
| 275 | + String.slice(value, 0, @metadata_max_display_length) <> "..." |
| 276 | + end |
| 277 | + end |
| 278 | + |
| 279 | + # Struct/map field access that tolerates either atom-keyed structs (Activity |
| 280 | + # entries, comments) without assuming a specific type. |
| 281 | + defp field(item, key), do: Map.get(item, key) |
| 282 | + |
| 283 | + defp present?(nil), do: false |
| 284 | + defp present?(""), do: false |
| 285 | + defp present?(_), do: true |
| 286 | + end |
| @@ -126,6 +126,7 @@ defmodule PhoenixKitWeb do | |
| 126 126 | import PhoenixKitWeb.Components.Core.UserInfo |
| 127 127 | import PhoenixKitWeb.Components.Core.Pagination |
| 128 128 | import PhoenixKitWeb.Components.Core.FileDisplay |
| 129 | + import PhoenixKitWeb.Components.Core.ResourceLink |
| 129 130 | import PhoenixKitWeb.Components.Core.EmailActivityBadges |
| 130 131 | import PhoenixKitWeb.Components.Core.MessageTagBadge |
| 131 132 | import PhoenixKitWeb.Components.Core.NumberFormatter |
| @@ -0,0 +1,76 @@ | |
| 1 | + defmodule PhoenixKitWeb.Components.Core.ResourceLink do |
| 2 | + @moduledoc """ |
| 3 | + Renders a resolved resource (from `PhoenixKit.ResourceLinks`) as a compact, |
| 4 | + clickable chip that deep-links to where the resource lives — the record an |
| 5 | + activity happened on, or a comment is attached to. |
| 6 | + |
| 7 | + Mirrors the comments moderation chip: a thumbnail (when the handler supplies |
| 8 | + `thumb_url`) or a type badge, plus the truncated title. Prefixed phoenix_kit |
| 9 | + routes SPA-navigate; host-template paths render as a real `href` (they may |
| 10 | + point at a controller page or external URL, where live navigation can't reach). |
| 11 | + |
| 12 | + Render only when a resolved info map is present: |
| 13 | + |
| 14 | + <.resource_link :if={@info} info={@info} /> |
| 15 | + """ |
| 16 | + use Phoenix.Component |
| 17 | + |
| 18 | + import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] |
| 19 | + |
| 20 | + alias PhoenixKit.ResourceLinks |
| 21 | + |
| 22 | + @doc """ |
| 23 | + Renders the resource chip for a resolved `info` map. |
| 24 | + """ |
| 25 | + attr :info, :map, required: true |
| 26 | + attr :class, :string, default: "" |
| 27 | + |
| 28 | + def resource_link(assigns) do |
| 29 | + ~H""" |
| 30 | + <.link |
| 31 | + :if={@info[:prefixed]} |
| 32 | + navigate={ResourceLinks.url(@info)} |
| 33 | + class={[chip_class(), @class]} |
| 34 | + title={@info[:full_title] || @info.title} |
| 35 | + > |
| 36 | + <.chip_body info={@info} /> |
| 37 | + </.link> |
| 38 | + <.link |
| 39 | + :if={!@info[:prefixed]} |
| 40 | + href={ResourceLinks.url(@info)} |
| 41 | + class={[chip_class(), @class]} |
| 42 | + title={@info[:full_title] || @info.title} |
| 43 | + > |
| 44 | + <.chip_body info={@info} /> |
| 45 | + </.link> |
| 46 | + """ |
| 47 | + end |
| 48 | + |
| 49 | + defp chip_class do |
| 50 | + "inline-flex items-center gap-1.5 max-w-[240px] py-0.5 pl-1 pr-2.5 rounded-full " <> |
| 51 | + "bg-base-200 hover:bg-base-300 transition-colors no-underline align-middle" |
| 52 | + end |
| 53 | + |
| 54 | + # The chip's inner content. The thumbnail is a CSS background (not an |
| 55 | + # `<img onerror=…>`): inline JS is blocked by a strict `script-src` CSP, and a |
| 56 | + # missing background image simply falls back to the placeholder colour. |
| 57 | + attr :info, :map, required: true |
| 58 | + |
| 59 | + defp chip_body(assigns) do |
| 60 | + ~H""" |
| 61 | + <span |
| 62 | + :if={@info[:thumb_url]} |
| 63 | + class="w-5 h-5 rounded-full bg-base-300 bg-cover bg-center shrink-0" |
| 64 | + style={"background-image: url('#{@info.thumb_url}')"} |
| 65 | + ></span> |
| 66 | + <.icon |
| 67 | + :if={!@info[:thumb_url]} |
| 68 | + name="hero-arrow-top-right-on-square" |
| 69 | + class="w-3.5 h-3.5 text-base-content/50 shrink-0 ml-1" |
| 70 | + /> |
| 71 | + <span class="truncate text-xs text-base-content/80 min-w-0"> |
| 72 | + {@info.title} |
| 73 | + </span> |
| 74 | + """ |
| 75 | + end |
| 76 | + end |
Loading more files…