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

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…