From 93fe2f9b61a76c2da9d2bfb7a86f5d6e230154a5 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 11 Nov 2025 19:04:58 +0200 Subject: [PATCH 01/24] Remove useless modal popup --- .../live/users/media.html.heex | 152 ------------------ .../live/users/media_detail.html.heex | 1 - 2 files changed, 153 deletions(-) diff --git a/lib/phoenix_kit_web/live/users/media.html.heex b/lib/phoenix_kit_web/live/users/media.html.heex index f4365f819..8616357fc 100644 --- a/lib/phoenix_kit_web/live/users/media.html.heex +++ b/lib/phoenix_kit_web/live/users/media.html.heex @@ -30,156 +30,6 @@ - <%!-- Image Preview Modal (Client-side controlled) --%> - - - - - - <%!-- Modal Control Script --%> - - <%!-- Uploaded Files --%> <%= if @total_count > 0 do %>
@@ -198,8 +48,6 @@ <.link navigate={PhoenixKit.Utils.Routes.path("/admin/users/media/#{file.file_id}")} class="group relative aspect-square bg-base-300 rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow" - data-file-id={file.file_id} - data-file-data={Jason.encode!(file)} > <% image_url = file.urls["medium"] || file.urls["original"] %> <%= if image_url do %> diff --git a/lib/phoenix_kit_web/live/users/media_detail.html.heex b/lib/phoenix_kit_web/live/users/media_detail.html.heex index 09c3efb76..791216fc0 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.html.heex +++ b/lib/phoenix_kit_web/live/users/media_detail.html.heex @@ -18,7 +18,6 @@ <%= if @file_data do %>

{@file_data.filename}

-

Shareable media file

<% else %>

File Not Found

The requested media file could not be found

From c53700fd53a0ef4581eb351fa970367d6d939787 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 11 Nov 2025 20:15:42 +0200 Subject: [PATCH 02/24] Updated media details page --- .../live/users/media_detail.ex | 62 +++++++++ .../live/users/media_detail.html.heex | 121 +++++++++++++++++- 2 files changed, 181 insertions(+), 2 deletions(-) diff --git a/lib/phoenix_kit_web/live/users/media_detail.ex b/lib/phoenix_kit_web/live/users/media_detail.ex index cbca8ab25..26c60efb3 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.ex +++ b/lib/phoenix_kit_web/live/users/media_detail.ex @@ -10,6 +10,7 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do require Logger alias PhoenixKit.Settings + alias PhoenixKit.Storage alias PhoenixKit.Storage.File alias PhoenixKit.Storage.FileInstance alias PhoenixKit.Storage.URLSigner @@ -43,6 +44,56 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do {:ok, socket} end + def handle_event("toggle_edit", _params, socket) do + {:noreply, assign(socket, :edit_mode, !socket.assigns.edit_mode)} + end + + def handle_event("save_metadata", params, socket) do + %{"title" => title, "description" => description, "tags" => tags_input} = params + + # Parse tags from comma-separated string + tags = + tags_input + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.filter(&(String.length(&1) > 0)) + + # Update metadata + updated_metadata = + (socket.assigns.file.metadata || %{}) + |> Map.put("title", title) + |> Map.put("description", description) + |> Map.put("tags", tags) + + case Storage.update_file(socket.assigns.file, %{metadata: updated_metadata}) do + {:ok, updated_file} -> + # Update file_data with new metadata + updated_file_data = + socket.assigns.file_data + |> Map.put(:title, title) + |> Map.put(:description, description) + |> Map.put(:tags, tags) + |> Map.put(:metadata, updated_metadata) + + socket = + socket + |> assign(:file, updated_file) + |> assign(:file_data, updated_file_data) + |> assign(:edit_mode, false) + |> put_flash(:info, "Metadata saved successfully!") + + {:noreply, socket} + + {:error, _changeset} -> + socket = put_flash(socket, :error, "Failed to save metadata") + {:noreply, socket} + end + end + + def handle_event("cancel_edit", _params, socket) do + {:noreply, assign(socket, :edit_mode, false)} + end + defp load_file_data(socket, file_id) do repo = Application.get_env(:phoenix_kit, :repo) import Ecto.Query @@ -63,6 +114,12 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do # Generate URLs from instances urls = generate_urls_from_instances(instances, file_id) + # Extract metadata + metadata = file.metadata || %{} + title = metadata["title"] || "" + description = metadata["description"] || "" + tags = metadata["tags"] || [] + # Build file data map file_data = %{ file_id: file.id, @@ -73,6 +130,10 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do size: file.size || 0, status: file.status, urls: urls, + title: title, + description: description, + tags: tags, + metadata: metadata, inserted_at: file.inserted_at, updated_at: file.updated_at } @@ -80,6 +141,7 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do socket |> assign(:file, file) |> assign(:file_data, file_data) + |> assign(:edit_mode, false) end end diff --git a/lib/phoenix_kit_web/live/users/media_detail.html.heex b/lib/phoenix_kit_web/live/users/media_detail.html.heex index 791216fc0..568779803 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.html.heex +++ b/lib/phoenix_kit_web/live/users/media_detail.html.heex @@ -54,6 +54,125 @@ <% end %>
+ <%!-- Metadata Section --%> +
+
+

Media Information

+ +
+ + <%= if @edit_mode do %> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ <% else %> +
+
+
+ Title + + <%= if String.length(@file_data.title) > 0 do %> + {@file_data.title} + <% else %> + + <% end %> + +
+
+ +
+
+ Description + + <%= if String.length(@file_data.description) > 0 do %> + {@file_data.description} + <% else %> + + <% end %> + +
+
+ +
+ Tags +
+ <%= if Enum.empty?(@file_data.tags) do %> + + <% else %> + <%= for tag <- @file_data.tags do %> + {tag} + <% end %> + <% end %> +
+
+
+ <% end %> +
+ + <%!-- Divider --%> +
+ <%!-- File Information --%>
@@ -119,8 +238,6 @@ <%!-- Download Links --%> <%= if map_size(@file_data.urls) > 0 do %> -
-

Download Variants:

From 228d5efe490494115c91ed548405030eb93e4296 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 11 Nov 2025 20:49:44 +0200 Subject: [PATCH 03/24] Made sure bucket shuffling does work when uploading files --- lib/phoenix_kit/storage.ex | 36 +++++++++++++++++- lib/phoenix_kit/storage/manager.ex | 40 ++++++++++---------- lib/phoenix_kit/storage/variant_generator.ex | 33 ++++++++++++++-- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/lib/phoenix_kit/storage.ex b/lib/phoenix_kit/storage.ex index f20e223c3..165f56fd2 100644 --- a/lib/phoenix_kit/storage.ex +++ b/lib/phoenix_kit/storage.ex @@ -476,6 +476,20 @@ defmodule PhoenixKit.Storage do repo().get_by(FileInstance, file_id: file_id, variant_name: variant_name) end + @doc """ + Gets the bucket IDs where a file instance is stored. + + Returns a list of bucket IDs from the file_locations for the given file instance. + """ + def get_file_instance_bucket_ids(file_instance_id) do + import Ecto.Query + + FileLocation + |> where([fl], fl.file_instance_id == ^file_instance_id and fl.status == "active") + |> select([fl], fl.bucket_id) + |> repo().all() + end + @doc """ Creates a new file instance. """ @@ -803,7 +817,7 @@ defmodule PhoenixKit.Storage do original_path = "#{file_path}/#{md5_hash}_original.#{ext}" case Manager.store_file(source_path, path_prefix: original_path) do - {:ok, _storage_info} -> + {:ok, storage_info} -> # Create file instance for original original_instance_attrs = %{ variant_name: "original", @@ -817,7 +831,9 @@ defmodule PhoenixKit.Storage do } case create_file_instance(original_instance_attrs) do - {:ok, _instance} -> + {:ok, instance} -> + # Create file location records for each bucket where the file was stored + _ = create_file_locations(instance.id, storage_info.bucket_ids, original_path) {:ok, file} {:error, changeset} -> @@ -1055,4 +1071,20 @@ defmodule PhoenixKit.Storage do random_name = :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower) Path.join(temp_dir, "phoenix_kit_#{random_name}") end + + defp create_file_locations(file_instance_id, bucket_ids, file_path) do + Enum.each(bucket_ids, fn bucket_id -> + location_attrs = %{ + path: file_path, + status: "active", + priority: 0, + file_instance_id: file_instance_id, + bucket_id: bucket_id + } + + repo().insert(%FileLocation{} |> FileLocation.changeset(location_attrs)) + end) + + :ok + end end diff --git a/lib/phoenix_kit/storage/manager.ex b/lib/phoenix_kit/storage/manager.ex index db97fc6e6..af41bfdc6 100644 --- a/lib/phoenix_kit/storage/manager.ex +++ b/lib/phoenix_kit/storage/manager.ex @@ -18,6 +18,7 @@ defmodule PhoenixKit.Storage.Manager do - `:redundancy_copies` - Number of copies to store (default: from settings) - `:priority_buckets` - List of specific bucket IDs to use (default: auto-select) + - `:force_bucket_ids` - List of specific bucket IDs to use (overrides priority_buckets) - `:generate_variants` - Whether to generate variants (default: from settings) ## Returns @@ -28,11 +29,15 @@ defmodule PhoenixKit.Storage.Manager do def store_file(source_path, opts \\ []) do # Get redundancy settings redundancy_copies = Keyword.get(opts, :redundancy_copies, get_redundancy_copies()) + force_bucket_ids = Keyword.get(opts, :force_bucket_ids, []) priority_buckets = Keyword.get(opts, :priority_buckets, []) _generate_variants = Keyword.get(opts, :generate_variants, get_auto_generate_variants()) + # Use force_bucket_ids if provided, otherwise use priority_buckets + buckets_to_use = if Enum.empty?(force_bucket_ids), do: priority_buckets, else: force_bucket_ids + # Select buckets for storage - buckets = select_buckets_for_storage(redundancy_copies, priority_buckets) + buckets = select_buckets_for_storage(redundancy_copies, buckets_to_use) if Enum.empty?(buckets) do {:error, "No available storage buckets"} @@ -113,13 +118,24 @@ defmodule PhoenixKit.Storage.Manager do defp select_buckets_for_storage(redundancy_copies, priority_buckets) do if Enum.empty?(priority_buckets) do - # Auto-select buckets based on priority and available space - get_enabled_buckets() - |> Enum.sort_by(&bucket_priority/1) + # Get fresh bucket list from database (don't use cache for selection) + # This ensures we get the current state and can shuffle properly + all_buckets = PhoenixKit.Storage.list_enabled_buckets() + + # Separate buckets by priority + {auto_priority_buckets, fixed_priority_buckets} = + Enum.split_with(all_buckets, &(&1.priority == 0)) + + # Shuffle auto-priority buckets (priority = 0) for random distribution + # Fixed priority buckets are deterministic + shuffled_auto = Enum.shuffle(auto_priority_buckets) + + # Combine: fixed priority buckets first (sorted), then shuffled auto-priority + (Enum.sort_by(fixed_priority_buckets, & &1.priority) ++ shuffled_auto) |> Enum.take(redundancy_copies) else # Use specified buckets - get_enabled_buckets() + PhoenixKit.Storage.list_enabled_buckets() |> Enum.filter(&(&1.id in priority_buckets)) |> Enum.take(redundancy_copies) end @@ -189,20 +205,6 @@ defmodule PhoenixKit.Storage.Manager do provider_module end - defp bucket_priority(bucket) do - if bucket.priority == 0 do - # Random priority - use available space as tiebreaker - used_space = PhoenixKit.Storage.calculate_bucket_usage(bucket.id) - # Large default - max_space = bucket.max_size_mb || 1_000_000 - free_space_ratio = (max_space - used_space) / max_space - # Negative for descending sort - {0, -free_space_ratio} - else - {bucket.priority, 0} - end - end - defp generate_destination_path(source_path, opts) do original_name = Path.basename(source_path) extension = Path.extname(original_name) diff --git a/lib/phoenix_kit/storage/variant_generator.ex b/lib/phoenix_kit/storage/variant_generator.ex index 279fc044e..9d83ffa11 100644 --- a/lib/phoenix_kit/storage/variant_generator.ex +++ b/lib/phoenix_kit/storage/variant_generator.ex @@ -116,7 +116,7 @@ defmodule PhoenixKit.Storage.VariantGenerator do process_variant(original_path, variant_path, file.mime_type, dimension), {:ok, file_stats} <- get_variant_file_stats(variant_path), {:ok, _storage_info} <- - store_variant_file(variant_path, variant_name, variant_storage_path), + store_variant_file(variant_path, variant_name, variant_storage_path, file.id), {:ok, instance} <- create_variant_instance( file, @@ -147,10 +147,37 @@ defmodule PhoenixKit.Storage.VariantGenerator do end end - defp store_variant_file(variant_path, variant_name, storage_path) do + defp store_variant_file(variant_path, variant_name, storage_path, file_id) do Logger.info("Storing variant #{variant_name} to storage buckets at path: #{storage_path}") - case Manager.store_file(variant_path, generate_variants: false, path_prefix: storage_path) do + # Get the bucket IDs from the original file instance if available + opts = + case file_id do + nil -> + [generate_variants: false, path_prefix: storage_path] + + file_id -> + # Get the original instance's bucket IDs + case Storage.get_file_instance_by_name(file_id, "original") do + %Storage.FileInstance{id: original_instance_id} -> + bucket_ids = Storage.get_file_instance_bucket_ids(original_instance_id) + + if Enum.empty?(bucket_ids) do + [generate_variants: false, path_prefix: storage_path] + else + [ + generate_variants: false, + path_prefix: storage_path, + force_bucket_ids: bucket_ids + ] + end + + nil -> + [generate_variants: false, path_prefix: storage_path] + end + end + + case Manager.store_file(variant_path, opts) do {:ok, _storage_info} = success -> Logger.info("Variant #{variant_name} stored successfully in buckets") success From ec1972d5b23723a91183cbc3e73588a49aaab5c9 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 11 Nov 2025 21:21:24 +0200 Subject: [PATCH 04/24] Added storage locations in the media details page --- .../live/users/media_detail.ex | 28 ++++++++++++++++++- .../live/users/media_detail.html.heex | 20 +++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_kit_web/live/users/media_detail.ex b/lib/phoenix_kit_web/live/users/media_detail.ex index 26c60efb3..1c0a591d2 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.ex +++ b/lib/phoenix_kit_web/live/users/media_detail.ex @@ -13,6 +13,7 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do alias PhoenixKit.Storage alias PhoenixKit.Storage.File alias PhoenixKit.Storage.FileInstance + alias PhoenixKit.Storage.FileLocation alias PhoenixKit.Storage.URLSigner alias PhoenixKit.Utils.Routes alias PhoenixKit.Utils.Date, as: UtilsDate @@ -114,6 +115,13 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do # Generate URLs from instances urls = generate_urls_from_instances(instances, file_id) + # Load file locations for the original instance + locations = + case Enum.find(instances, &(&1.variant_name == "original")) do + nil -> [] + original_instance -> load_file_locations(original_instance.id, repo) + end + # Extract metadata metadata = file.metadata || %{} title = metadata["title"] || "" @@ -135,7 +143,8 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do tags: tags, metadata: metadata, inserted_at: file.inserted_at, - updated_at: file.updated_at + updated_at: file.updated_at, + locations: locations } socket @@ -153,6 +162,23 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do end) end + # Load file locations with bucket information + defp load_file_locations(file_instance_id, repo) do + import Ecto.Query + + FileLocation + |> where([fl], fl.file_instance_id == ^file_instance_id and fl.status == "active") + |> preload(:bucket) + |> repo.all() + |> Enum.map(fn location -> + %{ + bucket_name: location.bucket.name, + bucket_provider: location.bucket.provider, + path: location.path + } + end) + end + # Format file size in human-readable format defp format_file_size(bytes) when is_integer(bytes) do cond do diff --git a/lib/phoenix_kit_web/live/users/media_detail.html.heex b/lib/phoenix_kit_web/live/users/media_detail.html.heex index 568779803..f3720e10a 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.html.heex +++ b/lib/phoenix_kit_web/live/users/media_detail.html.heex @@ -236,6 +236,26 @@
+ <%!-- Storage Locations --%> + <%= if not Enum.empty?(@file_data.locations) do %> +
+ +
+ <%= for location <- @file_data.locations do %> +
+
+
+

{location.bucket_name}

+

{location.path}

+

Provider: {String.upcase(location.bucket_provider)}

+
+
+
+ <% end %> +
+
+ <% end %> + <%!-- Download Links --%> <%= if map_size(@file_data.urls) > 0 do %>
From d744aa0733dd259bb3b109c58b020cfad3e884fa Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 17:53:09 +0200 Subject: [PATCH 05/24] Updated the Upload file button to cleaner layout --- .../components/core/file_upload.ex | 49 ++++++++------ lib/phoenix_kit_web/live/users/media.ex | 5 ++ .../live/users/media.html.heex | 67 ++++++++++++++----- 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/lib/phoenix_kit_web/components/core/file_upload.ex b/lib/phoenix_kit_web/components/core/file_upload.ex index 6a69ccb1a..bfffc87c8 100644 --- a/lib/phoenix_kit_web/components/core/file_upload.ex +++ b/lib/phoenix_kit_web/components/core/file_upload.ex @@ -33,29 +33,40 @@ defmodule PhoenixKitWeb.Components.Core.FileUpload do def file_upload(assigns) do ~H"""
- <%!-- Upload Form with phx-change on form not file input --%>
@upload.ref}> - - <.live_file_input upload={@upload} class="hidden" /> -
+ <%!-- Drag and Drop Zone --%> +
@upload.ref} + > + + <.live_file_input upload={@upload} class="hidden" /> +
- <%!-- File Type and Size Info --%> - <%= if @accept_description != nil or @max_size_description != nil do %> -

- <%= if @accept_description do %> - Supported formats: {@accept_description} + <%!-- File Type and Size Info --%> + <%= if @accept_description != nil or @max_size_description != nil do %> +

<%= if @max_size_description do %> -
+ Maximum file size: {@max_size_description} <% end %> - <% end %> - <%= if @max_size_description do %> - Maximum file size: {@max_size_description} - <% end %> -

- <% end %> +

+ <% end %> + <%!-- Active Uploads --%> <%= if length(@upload.entries) > 0 do %> diff --git a/lib/phoenix_kit_web/live/users/media.ex b/lib/phoenix_kit_web/live/users/media.ex index 5d064faa8..2eb13e3c6 100644 --- a/lib/phoenix_kit_web/live/users/media.ex +++ b/lib/phoenix_kit_web/live/users/media.ex @@ -40,6 +40,7 @@ defmodule PhoenixKitWeb.Live.Users.Media do |> assign(:project_title, settings["project_title"]) |> assign(:current_locale, locale) |> assign(:url_path, Routes.path("/admin/users/media")) + |> assign(:show_upload, false) {:ok, socket} end @@ -64,6 +65,10 @@ defmodule PhoenixKitWeb.Live.Users.Media do {:noreply, socket} end + def handle_event("toggle_upload", _params, socket) do + {:noreply, assign(socket, :show_upload, !socket.assigns.show_upload)} + end + def handle_event("validate", _params, socket) do # File selection event - files will auto-upload entries = socket.assigns.uploads.media_files.entries diff --git a/lib/phoenix_kit_web/live/users/media.html.heex b/lib/phoenix_kit_web/live/users/media.html.heex index 8616357fc..ab05b2a00 100644 --- a/lib/phoenix_kit_web/live/users/media.html.heex +++ b/lib/phoenix_kit_web/live/users/media.html.heex @@ -12,30 +12,44 @@

Manage user media and uploads

- <%!-- Upload Section --%> -
-
-

Upload Media

-

- Upload images, videos, or PDFs. Files are automatically processed and optimized. -

+ <%!-- Upload Section (Hidden by default) --%> + <%= if @show_upload do %> +
+
+

Upload Media

+

+ Upload images, videos, or PDFs. Files are automatically processed and optimized. +

- <.file_upload - upload={@uploads.media_files} - label="Upload Media Files" - icon="hero-cloud-arrow-up" - accept_description="JPG, PNG, WebP, MP4, WebM, PDF" - max_size_description="100MB per file" - /> + <.file_upload + upload={@uploads.media_files} + label="Upload Media Files" + icon="hero-cloud-arrow-up" + accept_description="JPG, PNG, WebP, MP4, WebM, PDF" + max_size_description="100MB per file" + /> +
-
+ <% end %> <%!-- Uploaded Files --%> <%= if @total_count > 0 do %>
-

Uploaded Files ({@total_count})

+
+

Uploaded Files ({@total_count})

+ +
<.pagination_info page={@current_page} per_page={@per_page} @@ -89,8 +103,25 @@
<% else %> -
-

No media uploaded yet. Upload your first file above!

+
+
+
+

Uploaded Files

+ +
+
+

No media uploaded yet. Upload your first file above!

+
+
<% end %>
From 6b6fc7d50451bdc25e8b15a8604b051897df6a46 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 18:00:22 +0200 Subject: [PATCH 06/24] Added uploaded by to media details page --- lib/phoenix_kit_web/live/users/media_detail.ex | 15 ++++++++++++++- .../live/users/media_detail.html.heex | 5 +++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_kit_web/live/users/media_detail.ex b/lib/phoenix_kit_web/live/users/media_detail.ex index 1c0a591d2..ceb0b1240 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.ex +++ b/lib/phoenix_kit_web/live/users/media_detail.ex @@ -128,6 +128,18 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do description = metadata["description"] || "" tags = metadata["tags"] || [] + # Get user information if available + user_name = + case file.user_id do + nil -> "Unknown" + user_id -> + alias_module = Application.get_env(:phoenix_kit, :users_module, PhoenixKit.Users.Auth.User) + case repo.get(alias_module, user_id) do + nil -> "Unknown" + user -> user.email + end + end + # Build file data map file_data = %{ file_id: file.id, @@ -144,7 +156,8 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do metadata: metadata, inserted_at: file.inserted_at, updated_at: file.updated_at, - locations: locations + locations: locations, + user_name: user_name } socket diff --git a/lib/phoenix_kit_web/live/users/media_detail.html.heex b/lib/phoenix_kit_web/live/users/media_detail.html.heex index f3720e10a..b7bccd2a0 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.html.heex +++ b/lib/phoenix_kit_web/live/users/media_detail.html.heex @@ -209,6 +209,11 @@

{@file_data.mime_type}

+
+ +

{@file_data.user_name}

+
+

From 2ab617a532c74dfd27c762da5433e475ce3b0d40 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 18:49:43 +0200 Subject: [PATCH 07/24] Updated the upload component to return the file id that was uploaded. --- .../components/core/file_upload.ex | 1 + lib/phoenix_kit_web/live/users/media.ex | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/phoenix_kit_web/components/core/file_upload.ex b/lib/phoenix_kit_web/components/core/file_upload.ex index bfffc87c8..2ecb154d8 100644 --- a/lib/phoenix_kit_web/components/core/file_upload.ex +++ b/lib/phoenix_kit_web/components/core/file_upload.ex @@ -29,6 +29,7 @@ defmodule PhoenixKitWeb.Components.Core.FileUpload do attr :icon, :string, default: "hero-cloud-arrow-up" attr :accept_description, :string, default: nil attr :max_size_description, :string, default: nil + attr :uploaded_file_ids, :list, default: nil, doc: "List of file IDs from last upload (for external use)" def file_upload(assigns) do ~H""" diff --git a/lib/phoenix_kit_web/live/users/media.ex b/lib/phoenix_kit_web/live/users/media.ex index 2eb13e3c6..ff013ef0f 100644 --- a/lib/phoenix_kit_web/live/users/media.ex +++ b/lib/phoenix_kit_web/live/users/media.ex @@ -41,6 +41,7 @@ defmodule PhoenixKitWeb.Live.Users.Media do |> assign(:current_locale, locale) |> assign(:url_path, Routes.path("/admin/users/media")) |> assign(:show_upload, false) + |> assign(:last_uploaded_file_ids, []) {:ok, socket} end @@ -86,6 +87,13 @@ defmodule PhoenixKitWeb.Live.Users.Media do {:noreply, cancel_upload(socket, :media_files, ref)} end + def handle_info({:file_uploaded, file_id}, socket) do + # This event can be used by other modules listening to uploaded files + # For example, avatar upload systems can listen for this event + Logger.info("File uploaded with ID: #{file_id}") + {:noreply, socket} + end + def handle_info(:check_uploads_complete, socket) do entries = socket.assigns.uploads.media_files.entries @@ -168,16 +176,23 @@ defmodule PhoenixKitWeb.Live.Users.Media do {refreshed_files, total_count} = load_existing_files(page, per_page) total_pages = ceil(total_count / per_page) + # Extract file IDs for callbacks + file_ids = Enum.map(uploaded_files, &get_file_id/1) + socket = socket |> assign(:uploaded_files, refreshed_files) |> assign(:total_count, total_count) |> assign(:total_pages, total_pages) + |> assign(:last_uploaded_file_ids, file_ids) |> put_flash(:info, "Upload successful! #{length(uploaded_files)} file(s) processed") {:noreply, socket} end + defp get_file_id({:ok, %{file_id: file_id}}), do: file_id + defp get_file_id(_), do: nil + # Generate URLs from pre-loaded instances (no database query needed) defp generate_urls_from_instances(instances, file_id) do Enum.reduce(instances, %{}, fn instance, acc -> From 74d2d045d481e19abc068c55c3585c1e42982bfb Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 21:16:17 +0200 Subject: [PATCH 08/24] Created single public update_user_avatar that would create instances and update the avatar id --- lib/phoenix_kit/users/auth.ex | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/lib/phoenix_kit/users/auth.ex b/lib/phoenix_kit/users/auth.ex index ed37cf08d..929c1a4c1 100644 --- a/lib/phoenix_kit/users/auth.ex +++ b/lib/phoenix_kit/users/auth.ex @@ -910,6 +910,89 @@ defmodule PhoenixKit.Users.Auth do update_user_custom_fields(user, merged_custom_fields) end + @doc """ + Update a user's avatar by storing the file and saving the file ID. + + This function handles the complete avatar upload workflow: + 1. Stores the file in configured storage buckets + 2. Automatically queues background job for variant generation + 3. Saves the file ID to the user's custom_fields + + This is a convenience function that combines file storage with user update. + Can be called from any context (LiveView, controllers, scripts, etc.) outside + of the PhoenixKit project. + + ## Parameters + - `user` - The User struct to update + - `file_path` - Path to the uploaded file (temporary location) + - `filename` - Original filename for the upload + - `user_id` - The user ID owning this file (defaults to user.id) + + ## Returns + - `{:ok, user}` - Avatar saved successfully + - `{:error, reason}` - File storage or update failed + + ## Examples + + # Store avatar in default location with automatic variant generation + {:ok, updated_user} = Auth.update_user_avatar(user, "/tmp/upload_xyz", "avatar.jpg") + + # Store with explicit user_id (for custom workflows) + {:ok, updated_user} = Auth.update_user_avatar(user, "/tmp/upload_xyz", "avatar.jpg", custom_user_id) + + ## Automatically Generated Variants + The storage layer automatically generates these image variants: + - original - Full-size image + - large - 800x800px + - medium - 400x400px + - small - 200x200px + - thumbnail - 100x100px + """ + def update_user_avatar(%User{} = user, file_path, filename, user_id \\ nil) do + user_id = user_id || user.id + + # Calculate file hash + file_hash = calculate_file_hash(file_path) + + # Get file extension + ext = Path.extname(filename) |> String.replace_leading(".", "") + + # Store file in buckets (automatically queues ProcessFileJob for variants) + case PhoenixKit.Storage.store_file_in_buckets( + file_path, + "image", + user_id, + file_hash, + ext, + filename + ) do + {:ok, file} -> + # Save the file ID to user's custom fields + update_user_fields(user, %{"avatar_file_id" => file.id}) + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Calculate SHA256 hash of a file. + + Used internally for file integrity verification. + + ## Parameters + - `file_path` - Path to the file + + ## Returns + - String containing the lowercase hexadecimal SHA256 hash + """ + def calculate_file_hash(file_path) do + file_path + |> File.read!() + |> then(fn data -> :crypto.hash(:sha256, data) end) + |> Base.encode16(case: :lower) + end + @doc """ Bulk update multiple users with the same field values. From 0f6019a1cba33eabd049a4d609175a03f914b15a Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 21:18:25 +0200 Subject: [PATCH 09/24] Added proccessing file in the upload function to create instances --- lib/phoenix_kit/storage.ex | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/phoenix_kit/storage.ex b/lib/phoenix_kit/storage.ex index 165f56fd2..b98476fd2 100644 --- a/lib/phoenix_kit/storage.ex +++ b/lib/phoenix_kit/storage.ex @@ -834,6 +834,13 @@ defmodule PhoenixKit.Storage do {:ok, instance} -> # Create file location records for each bucket where the file was stored _ = create_file_locations(instance.id, storage_info.bucket_ids, original_path) + + # Queue background job for variant processing + _ = + %{file_id: file.id, user_id: user_id, filename: orig_filename} + |> PhoenixKit.Storage.Workers.ProcessFileJob.new() + |> Oban.insert() + {:ok, file} {:error, changeset} -> From 9759e979dfc750d540c23cc90e03ed7aa100bf1d Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 21:19:36 +0200 Subject: [PATCH 10/24] Updated media upload --- .../components/core/file_upload.ex | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_kit_web/components/core/file_upload.ex b/lib/phoenix_kit_web/components/core/file_upload.ex index 2ecb154d8..45ba7c50b 100644 --- a/lib/phoenix_kit_web/components/core/file_upload.ex +++ b/lib/phoenix_kit_web/components/core/file_upload.ex @@ -29,9 +29,23 @@ defmodule PhoenixKitWeb.Components.Core.FileUpload do attr :icon, :string, default: "hero-cloud-arrow-up" attr :accept_description, :string, default: nil attr :max_size_description, :string, default: nil - attr :uploaded_file_ids, :list, default: nil, doc: "List of file IDs from last upload (for external use)" + + attr :uploaded_file_ids, :list, + default: nil, + doc: "List of file IDs from last upload (for external use)" + + attr :variant, :string, + default: "full", + doc: "Display variant - 'full' for drag-drop zone, 'button' for simple button only" def file_upload(assigns) do + case assigns.variant do + "button" -> button_upload(assigns) + _ -> full_upload(assigns) + end + end + + defp full_upload(assigns) do ~H"""

@upload.ref}> @@ -107,4 +121,51 @@ defmodule PhoenixKitWeb.Components.Core.FileUpload do
""" end + + defp button_upload(assigns) do + ~H""" +
+ @upload.ref}> + + <.live_file_input upload={@upload} class="hidden" /> + + + <%!-- Active Uploads --%> + <%= if length(@upload.entries) > 0 do %> +
+ <%= for entry <- @upload.entries do %> +
+
+

{entry.client_name}

+ + {entry.progress}% + +
+ + <%!-- Cancel Button --%> + +
+ <% end %> +
+ <% end %> +
+ """ + end end From e0ecbd8bec226b601363d34ec936a7b9f17ac702 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 21:20:55 +0200 Subject: [PATCH 11/24] Moved the process file logic --- lib/phoenix_kit_web/live/users/media.ex | 37 ++++--------------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/lib/phoenix_kit_web/live/users/media.ex b/lib/phoenix_kit_web/live/users/media.ex index ff013ef0f..03a200358 100644 --- a/lib/phoenix_kit_web/live/users/media.ex +++ b/lib/phoenix_kit_web/live/users/media.ex @@ -12,7 +12,7 @@ defmodule PhoenixKitWeb.Live.Users.Media do alias PhoenixKit.Settings alias PhoenixKit.Storage.FileInstance alias PhoenixKit.Storage.URLSigner - alias PhoenixKit.Storage.Workers.ProcessFileJob + alias PhoenixKit.Users.Auth alias PhoenixKit.Utils.Routes def mount(params, _session, socket) do @@ -132,7 +132,7 @@ defmodule PhoenixKitWeb.Live.Users.Media do file_size = stat.size # Calculate hash - file_hash = calculate_file_hash(path) + file_hash = Auth.calculate_file_hash(path) # Store file in storage case PhoenixKit.Storage.store_file_in_buckets( @@ -144,14 +144,8 @@ defmodule PhoenixKitWeb.Live.Users.Media do entry.client_name ) do {:ok, file} -> - # Queue background job for processing - _job = - %{file_id: file.id, user_id: user_id, filename: entry.client_name} - |> ProcessFileJob.new() - |> Oban.insert() - - # Generate URLs for available variants (start with original) - urls = generate_file_urls(file.id) + # Note: ProcessFileJob is now automatically queued in Storage.store_file_in_buckets + # Variants will be generated asynchronously and loaded when page data is refreshed {:ok, %{ @@ -161,7 +155,7 @@ defmodule PhoenixKitWeb.Live.Users.Media do mime_type: mime_type, size: file_size, status: file.status, - urls: urls + urls: %{} }} {:error, reason} -> @@ -201,27 +195,6 @@ defmodule PhoenixKitWeb.Live.Users.Media do end) end - # Legacy function for cases where we need to query a single file's instances - defp generate_file_urls(file_id) do - import Ecto.Query - - repo = Application.get_env(:phoenix_kit, :repo) - - instances = - FileInstance - |> where([fi], fi.file_id == ^file_id) - |> repo.all() - - generate_urls_from_instances(instances, file_id) - end - - defp calculate_file_hash(file_path) do - file_path - |> Elixir.File.read!() - |> then(fn data -> :crypto.hash(:sha256, data) end) - |> Base.encode16(case: :lower) - end - defp determine_file_type(mime_type) do cond do String.starts_with?(mime_type, "image/") -> "image" From 86d0ee6202543260fe38a017090850a030c5dfef Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 21:21:29 +0200 Subject: [PATCH 12/24] Added avatar upload logic for the user edit page --- lib/phoenix_kit_web/users/user_form.ex | 131 ++++++++++++++++++ lib/phoenix_kit_web/users/user_form.html.heex | 79 +++++++++++ 2 files changed, 210 insertions(+) diff --git a/lib/phoenix_kit_web/users/user_form.ex b/lib/phoenix_kit_web/users/user_form.ex index e5716c348..104d0d481 100644 --- a/lib/phoenix_kit_web/users/user_form.ex +++ b/lib/phoenix_kit_web/users/user_form.ex @@ -8,6 +8,8 @@ defmodule PhoenixKitWeb.Users.UserForm do """ use PhoenixKitWeb, :live_view + require Logger + alias PhoenixKit.Settings alias PhoenixKit.Users.Auth alias PhoenixKit.Users.CustomFields @@ -34,6 +36,12 @@ defmodule PhoenixKitWeb.Users.UserForm do socket = socket + |> allow_upload(:avatar, + accept: ["image/*"], + max_entries: 1, + max_file_size: 10_000_000, + auto_upload: true + ) |> assign(:current_locale, locale) |> assign(:mode, mode) |> assign(:user_id, user_id) @@ -45,12 +53,30 @@ defmodule PhoenixKitWeb.Users.UserForm do |> assign(:all_roles, all_roles) |> assign(:pending_roles, []) |> assign(:default_role, default_role) + |> assign(:last_uploaded_avatar_id, nil) |> load_user_data(mode, user_id) |> load_form_data() {:ok, socket} end + def handle_event("validate", %{"_target" => ["avatar"]}, socket) do + # Avatar file selection event - files will auto-upload + entries = socket.assigns.uploads.avatar.entries + Logger.info("avatar validate event: entries=#{length(entries)}") + + if entries != [] do + Logger.info("avatar validate: scheduling check_uploads_complete") + Process.send_after(self(), :check_avatar_uploads_complete, 500) + end + + {:noreply, socket} + end + + def handle_event("cancel_upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :avatar, ref)} + end + def handle_event("validate_user", %{"user" => user_params}, socket) do # Filter password from params if password field is not shown filtered_params = @@ -558,4 +584,109 @@ defmodule PhoenixKitWeb.Users.UserForm do Roles.sync_user_roles(user, pending_roles) end end + + def handle_info(:check_avatar_uploads_complete, socket) do + entries = socket.assigns.uploads.avatar.entries + + Logger.info( + "check_avatar_uploads_complete: entries=#{length(entries)}, done?=#{inspect(Enum.map(entries, & &1.done?))}" + ) + + # Check if all entries are done uploading + if entries != [] && Enum.all?(entries, & &1.done?) do + Logger.info("Avatar uploads done! Processing...") + # All done - process them + process_avatar_uploads(socket) + else + # Still uploading - check again later + Logger.info("Still uploading avatar, checking again...") + Process.send_after(self(), :check_avatar_uploads_complete, 500) + {:noreply, socket} + end + end + + defp process_avatar_uploads(socket) do + # Process uploaded avatar files + uploaded_avatars = + consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry -> + # Get file info + ext = Path.extname(entry.client_name) |> String.replace_leading(".", "") + + # Get current user + current_user = socket.assigns.phoenix_kit_current_user + user_id = if current_user, do: current_user.id, else: 1 + + # Get file size + {:ok, stat} = Elixir.File.stat(path) + file_size = stat.size + + # Calculate hash + file_hash = Auth.calculate_file_hash(path) + + # Store file in storage + case PhoenixKit.Storage.store_file_in_buckets( + path, + "image", + user_id, + file_hash, + ext, + entry.client_name + ) do + {:ok, file} -> + Logger.info("Avatar file stored with ID: #{file.id}") + + # Note: ProcessFileJob is now automatically queued in Storage.store_file_in_buckets + + {:ok, + %{ + file_id: file.id, + filename: entry.client_name, + size: file_size + }} + + {:error, reason} -> + Logger.error("Storage Error: #{inspect(reason)}") + {:error, reason} + end + end) + + # Extract file IDs for use + Logger.info("Uploaded avatars: #{inspect(uploaded_avatars)}") + avatar_file_ids = Enum.map(uploaded_avatars, &get_avatar_file_id/1) + Logger.info("Avatar file IDs: #{inspect(avatar_file_ids)}") + avatar_file_id = List.first(avatar_file_ids) + Logger.info("First avatar file ID: #{inspect(avatar_file_id)}") + + # Save the avatar file ID to the user's custom fields + socket = + if avatar_file_id && avatar_file_id != nil do + user = socket.assigns.user + + case Auth.update_user_fields(user, %{"avatar_file_id" => avatar_file_id}) do + {:ok, updated_user} -> + Logger.info("Avatar file ID saved: #{avatar_file_id}") + + socket + |> assign(:user, updated_user) + |> assign(:last_uploaded_avatar_id, avatar_file_id) + |> put_flash(:info, "Avatar uploaded successfully!") + + {:error, changeset} -> + Logger.error("Failed to save avatar file ID: #{inspect(changeset)}") + + socket + |> assign(:last_uploaded_avatar_id, avatar_file_id) + |> put_flash(:error, "Avatar uploaded but failed to save to profile") + end + else + socket + |> put_flash(:error, "Failed to upload avatar") + end + + {:noreply, socket} + end + + defp get_avatar_file_id(%{file_id: file_id}), do: file_id + defp get_avatar_file_id({:ok, %{file_id: file_id}}), do: file_id + defp get_avatar_file_id(_), do: nil end diff --git a/lib/phoenix_kit_web/users/user_form.html.heex b/lib/phoenix_kit_web/users/user_form.html.heex index fe75c15e8..fa4cb21df 100644 --- a/lib/phoenix_kit_web/users/user_form.html.heex +++ b/lib/phoenix_kit_web/users/user_form.html.heex @@ -32,6 +32,85 @@ <%!-- User Form --%>
+ <%!-- Avatar Upload Section (Edit mode only) --%> + <%= if @mode == :edit do %> +
+
+ <%!-- Avatar Preview (Left) --%> +
+ <% avatar_file_id = + @last_uploaded_avatar_id || + (@user.custom_fields && @user.custom_fields["avatar_file_id"]) %> + <% variant = if @last_uploaded_avatar_id, do: "original", else: "medium" %> +
+ <%= if avatar_file_id do %> + <% avatar_url = + PhoenixKit.Storage.URLSigner.signed_url(avatar_file_id, variant) %> + Avatar + + <% else %> + <% initials = + (@user.first_name || @user.email) + |> String.first() + |> String.upcase() %> +
+

{initials}

+
+ <% end %> +
+
+ + <%!-- Upload Controls (Right) --%> +
+
+
+ +

+ Supports JPG, PNG, WebP formats up to 10MB +

+
+ + <%!-- Avatar Upload Component (Separate form to avoid validate_user conflict) --%> + <.file_upload + upload={@uploads.avatar} + label="Choose Avatar" + icon="hero-cloud-arrow-up" + variant="button" + /> + + <%!-- Success Message --%> + <%= if @last_uploaded_avatar_id do %> +
+ <.icon name="hero-check-circle" class="w-5 h-5" /> +
+

Avatar uploaded successfully!

+

{@last_uploaded_avatar_id}

+
+
+ <% end %> +
+
+
+
+ +
+ <% end %> + <.form :let={form} for={@changeset} From 1aef1ba203a35a81637392563fc52c2b3b134b0c Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 21:21:34 +0200 Subject: [PATCH 13/24] Mix format --- lib/phoenix_kit/storage/manager.ex | 3 ++- lib/phoenix_kit_web/live/users/media.html.heex | 8 +++++--- lib/phoenix_kit_web/live/users/media_detail.ex | 8 ++++++-- .../live/users/media_detail.html.heex | 12 +++++++++--- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lib/phoenix_kit/storage/manager.ex b/lib/phoenix_kit/storage/manager.ex index af41bfdc6..8a571f42a 100644 --- a/lib/phoenix_kit/storage/manager.ex +++ b/lib/phoenix_kit/storage/manager.ex @@ -34,7 +34,8 @@ defmodule PhoenixKit.Storage.Manager do _generate_variants = Keyword.get(opts, :generate_variants, get_auto_generate_variants()) # Use force_bucket_ids if provided, otherwise use priority_buckets - buckets_to_use = if Enum.empty?(force_bucket_ids), do: priority_buckets, else: force_bucket_ids + buckets_to_use = + if Enum.empty?(force_bucket_ids), do: priority_buckets, else: force_bucket_ids # Select buckets for storage buckets = select_buckets_for_storage(redundancy_copies, buckets_to_use) diff --git a/lib/phoenix_kit_web/live/users/media.html.heex b/lib/phoenix_kit_web/live/users/media.html.heex index ab05b2a00..d95183c37 100644 --- a/lib/phoenix_kit_web/live/users/media.html.heex +++ b/lib/phoenix_kit_web/live/users/media.html.heex @@ -41,7 +41,7 @@

Uploaded Files ({@total_count})

-

No media uploaded yet. Upload your first file above!

+

+ No media uploaded yet. Upload your first file above! +

diff --git a/lib/phoenix_kit_web/live/users/media_detail.ex b/lib/phoenix_kit_web/live/users/media_detail.ex index ceb0b1240..9dbba54d2 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.ex +++ b/lib/phoenix_kit_web/live/users/media_detail.ex @@ -131,9 +131,13 @@ defmodule PhoenixKitWeb.Live.Users.MediaDetail do # Get user information if available user_name = case file.user_id do - nil -> "Unknown" + nil -> + "Unknown" + user_id -> - alias_module = Application.get_env(:phoenix_kit, :users_module, PhoenixKit.Users.Auth.User) + alias_module = + Application.get_env(:phoenix_kit, :users_module, PhoenixKit.Users.Auth.User) + case repo.get(alias_module, user_id) do nil -> "Unknown" user -> user.email diff --git a/lib/phoenix_kit_web/live/users/media_detail.html.heex b/lib/phoenix_kit_web/live/users/media_detail.html.heex index b7bccd2a0..ce1a994b5 100644 --- a/lib/phoenix_kit_web/live/users/media_detail.html.heex +++ b/lib/phoenix_kit_web/live/users/media_detail.html.heex @@ -244,15 +244,21 @@ <%!-- Storage Locations --%> <%= if not Enum.empty?(@file_data.locations) do %>
- +
<%= for location <- @file_data.locations do %>

{location.bucket_name}

-

{location.path}

-

Provider: {String.upcase(location.bucket_provider)}

+

+ {location.path} +

+

+ Provider: {String.upcase(location.bucket_provider)} +

From 224d84a5ce2bdb348411246a021de53d8ce07c79 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Wed, 12 Nov 2025 21:31:10 +0200 Subject: [PATCH 14/24] Updated the navbar to show the users avatar --- lib/phoenix_kit_web/components/admin_nav.ex | 50 +++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index f68ec1662..72b31634e 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -270,6 +270,14 @@ defmodule PhoenixKitWeb.Components.AdminNav do attr(:current_locale, :string, default: "en") def admin_user_dropdown(assigns) do + user = PhoenixKit.Users.Auth.Scope.user(assigns.scope) + avatar_file_id = user && user.custom_fields && user.custom_fields["avatar_file_id"] + + assigns = + assigns + |> assign(:avatar_file_id, avatar_file_id) + |> assign(:user, user) + ~H""" <%= if @scope && PhoenixKit.Users.Auth.Scope.authenticated?(@scope) do %>