From 16363e5842351a5716e790e7bb7a236198cc6120 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Sat, 18 Jul 2026 23:45:37 +0300 Subject: [PATCH 1/6] Default folder header size to small MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New folders open with a small hero header instead of medium (less vertical space up front). Changed the schema default on Folder, plus migration V152 that flips the phoenix_kit_media_folders.header_size column default 'medium' -> 'small' and backfills existing 'medium' rows to 'small'. 'medium' was the old default, so a stored 'medium' reads as "never touched" and resets. 'large' was never a default, so it's a deliberate choice and is left alone; existing 'small' is untouched. A folder someone deliberately set to medium also resets (medium and default-medium are indistinguishable in the data) — documented in the migration; medium stays re-pickable from the header control. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MsjUy1HnuJnCSrqdbnANYL --- lib/modules/storage/schemas/folder.ex | 7 ++- lib/phoenix_kit/migrations/postgres.ex | 10 ++- lib/phoenix_kit/migrations/postgres/v153.ex | 68 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 lib/phoenix_kit/migrations/postgres/v153.ex diff --git a/lib/modules/storage/schemas/folder.ex b/lib/modules/storage/schemas/folder.ex index 3a811b737..a74ef2578 100644 --- a/lib/modules/storage/schemas/folder.ex +++ b/lib/modules/storage/schemas/folder.ex @@ -23,10 +23,13 @@ defmodule PhoenixKit.Modules.Storage.Folder do # Folder hero-header customization. The cover (background) and logo (icon) # are media files living in the folder, excluded from its visible listing. # `header_size` is small/medium/large; the `header_show_*` flags toggle each - # header element. All defaulted so existing folders render as before. + # header element. New folders default to a small header (less vertical + # space up front). Existing rows keep their stored size — the DB column + # default (v134, "medium") only applies to raw inserts, which never happen + # here; folders are always created via changeset, so this default wins. field :cover_file_uuid, UUIDv7 field :logo_file_uuid, UUIDv7 - field :header_size, :string, default: "medium" + field :header_size, :string, default: "small" field :header_show_title, :boolean, default: true field :header_show_icon, :boolean, default: true field :header_show_creator, :boolean, default: true diff --git a/lib/phoenix_kit/migrations/postgres.ex b/lib/phoenix_kit/migrations/postgres.ex index 24695daa0..9bff02d3a 100644 --- a/lib/phoenix_kit/migrations/postgres.ex +++ b/lib/phoenix_kit/migrations/postgres.ex @@ -529,7 +529,13 @@ defmodule PhoenixKit.Migrations.Postgres do - Replaces unique index with partial index (slug-mode only, WHERE slug IS NOT NULL) - Adds unique index on `(group_uuid, post_date, post_time)` for timestamp-mode posts - ### V152 - Newsletters/CRM/Core restructuring (accumulator) ⚡ LATEST + ### V153 - Folder header size defaults to small ⚡ LATEST + - Flips `phoenix_kit_media_folders.header_size` column default from + 'medium' (V134) to 'small', and backfills existing 'medium' rows to + 'small' ('medium' was the old default, so it reads as untouched; + 'large' is a deliberate choice and is left alone) + + ### V152 - Newsletters/CRM/Core restructuring (accumulator) - Unreleased — per the "one open migration" rule, every DDL step of the restructuring plan lands in V152 as its own section until it ships; later stages append here rather than opening V153. @@ -1323,7 +1329,7 @@ defmodule PhoenixKit.Migrations.Postgres do alias PhoenixKit.Migrations.Postgres.Helpers @initial_version 1 - @current_version 152 + @current_version 153 @default_prefix "public" # First version whose SQL references uuid_generate_v7(). Chains that diff --git a/lib/phoenix_kit/migrations/postgres/v153.ex b/lib/phoenix_kit/migrations/postgres/v153.ex new file mode 100644 index 000000000..3a3794c83 --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v153.ex @@ -0,0 +1,68 @@ +defmodule PhoenixKit.Migrations.Postgres.V153 do + @moduledoc """ + V153: folder header size defaults to small. + + New folders now open with a small hero header (see the schema default on + `PhoenixKit.Modules.Storage.Folder`). This migration brings existing rows + and the DB column default in line: + + * **Column default** `phoenix_kit_media_folders.header_size` flips from + `'medium'` (set in V134) to `'small'`, so raw inserts match the + changeset default. + + * **Backfill** every folder currently on `'medium'` → `'small'`. + `'medium'` was the *old default*, so a stored `'medium'` is + indistinguishable from "never touched" — those reset to small. + `'large'` was never a default, so any `'large'` is a deliberate + choice and is left alone; existing `'small'` rows are unaffected. + + There is no stored "user customised this" signal, so a folder someone + deliberately set to `'medium'` also resets — an accepted trade-off, since + medium and default-medium can't be told apart. Users can re-pick medium + from the header-size control any time. + + Idempotent: the backfill's `WHERE header_size = 'medium'` and the default + swap are safe to re-run. + """ + + use Ecto.Migration + + def up(opts) do + p = prefix_str(Map.get(opts, :prefix, "public")) + + execute(""" + ALTER TABLE #{p}phoenix_kit_media_folders + ALTER COLUMN header_size SET DEFAULT 'small' + """) + + execute(""" + UPDATE #{p}phoenix_kit_media_folders + SET header_size = 'small' + WHERE header_size = 'medium' + """) + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '153'") + end + + @doc """ + Rolls V152 back. + + Restores the column default to `'medium'` (its V134 value). **Lossy:** the + `medium → small` backfill is not reversed — the folders that were reset + can't be told apart from folders genuinely on small, so their sizes stay + as they are. + """ + def down(opts) do + p = prefix_str(Map.get(opts, :prefix, "public")) + + execute(""" + ALTER TABLE #{p}phoenix_kit_media_folders + ALTER COLUMN header_size SET DEFAULT 'medium' + """) + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '152'") + end + + defp prefix_str("public"), do: "public." + defp prefix_str(prefix), do: "#{prefix}." +end From 1ffb274773fd4ec6723350d6c8cb1e30ace84238 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Sat, 18 Jul 2026 23:45:37 +0300 Subject: [PATCH 2/6] Fix folder cover/logo files being hidden from the folder listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a file as a folder's hero background or icon dropped it from that folder's grid — list_files_in_scope ran through exclude_folder_header_assets, which filtered out cover_file_uuid / logo_file_uuid. A cover/logo is still a normal file in the folder and should show like any other. Removed the exclusion (and its now-dead helper); count_folder_contents never excluded them, so the grid now matches the header's file count instead of disagreeing with it. Regression test asserts a cover + logo + plain file all list (verified to fail with the exclusion in place). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MsjUy1HnuJnCSrqdbnANYL --- lib/modules/storage/storage.ex | 21 --------------------- test/integration/storage/scope_test.exs | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/lib/modules/storage/storage.ex b/lib/modules/storage/storage.ex index 0cef0a848..3b95be315 100644 --- a/lib/modules/storage/storage.ex +++ b/lib/modules/storage/storage.ex @@ -1503,7 +1503,6 @@ defmodule PhoenixKit.Modules.Storage do build_scope_file_query(scope_folder_id, folder_uuid, search, include_orphaned) |> where([f], f.status != "trashed") |> exclude_system_managed() - |> exclude_folder_header_assets(folder_uuid) |> maybe_filter_file_type(file_type) total = repo().aggregate(query, :count, :uuid) @@ -1524,26 +1523,6 @@ defmodule PhoenixKit.Modules.Storage do defp maybe_filter_file_type(query, type) when type in [nil, "all", ""], do: query defp maybe_filter_file_type(query, type), do: where(query, [f], f.file_type == ^type) - # A folder's own cover/logo are folder assets, not part of its visible file - # listing — drop them from the per-folder grid. They remain real files - # (re-selectable via the header's media picker); we just don't show them as - # loose files in the folder they decorate. Only applies when listing a - # specific folder; flat views (all/orphaned/search) pass folder_uuid = nil. - defp exclude_folder_header_assets(query, nil), do: query - - defp exclude_folder_header_assets(query, folder_uuid) do - case get_folder(folder_uuid) do - %{} = folder -> - case Enum.reject([folder.cover_file_uuid, folder.logo_file_uuid], &is_nil/1) do - [] -> query - excluded -> where(query, [f], f.uuid not in ^excluded) - end - - _ -> - query - end - end - # Sort whitelist for the media browser toolbar — defaults to newest first. # Every order carries `f.uuid` as a stable tiebreaker so equal values # (same size, same name, same insert time) can't shuffle across pages. diff --git a/test/integration/storage/scope_test.exs b/test/integration/storage/scope_test.exs index 0dd117e11..076367b51 100644 --- a/test/integration/storage/scope_test.exs +++ b/test/integration/storage/scope_test.exs @@ -298,6 +298,31 @@ defmodule PhoenixKit.Integration.Storage.ScopeTest do Storage.list_files_in_scope(scope.uuid, folder_uuid: sibling.uuid) end + test "a file used as the folder's cover/logo still appears in its listing" do + # Setting a folder's hero background/icon must not hide that file from + # the folder — it stays a normal file in the folder. Regression: the + # listing used to drop cover_file_uuid / logo_file_uuid. + folder = create_folder!(%{name: "decorated_#{System.unique_integer([:positive])}"}) + cover = create_file!(folder.uuid) + logo = create_file!(folder.uuid) + plain = create_file!(folder.uuid) + + {:ok, _} = + Storage.update_folder( + folder, + %{cover_file_uuid: cover.uuid, logo_file_uuid: logo.uuid}, + nil + ) + + {files, count} = Storage.list_files_in_scope(nil, folder_uuid: folder.uuid) + uuids = Enum.map(files, & &1.uuid) + + assert cover.uuid in uuids + assert logo.uuid in uuids + assert plain.uuid in uuids + assert count == 3 + end + test "folder_uuid within scope filters to that folder" do %{scope: scope, child_a: child_a, child_b: child_b} = build_tree() f_a = create_file!(child_a.uuid) From 469008dc6607205a989be1db2565d557a582e4dd Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Sun, 19 Jul 2026 00:01:32 +0300 Subject: [PATCH 3/6] Fix Estonian mistranslations on the media page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 media-page strings were mistranslated — mostly fuzzy/copy-paste artifacts from gettext.merge matching to similar strings. Wrong-meaning ones: "Create folder" was "Loo roll" (Create role), "Folder name" was "Kaust teisaldatud" (Folder moved), "Documents" was "Kommentaarid" (Comments), "Filter" was "Fail" (File), "Large" was "Sihtmärk" (Target), "Medium" was "Meedia" (Media), "Header size" was "Pealkiri" (Title), "Add description" was "Kirjeldus" (Description), "Show description" was "Kirjeldus puudub" (No description), "Choose" was "Sulge" (Close), "Close search" was "Tühjenda" (Clear), and the audio-tag fallback said "video-elementi". Also completed "Name A–Z"/"Z–A" (were just "Nimi"), filled the empty "Play / Pause", fixed "Archives" (Arhiveeritud -> Arhiivid), dropped a spurious "(lauaarvuti)" from "Background image", and restored the "add one" prompt on "No description — add one". Every affected string is media-only (verified), so the global catalog fix is correct everywhere it's used. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MsjUy1HnuJnCSrqdbnANYL --- priv/gettext/et/LC_MESSAGES/default.po | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/priv/gettext/et/LC_MESSAGES/default.po b/priv/gettext/et/LC_MESSAGES/default.po index 96aff7042..2f428f779 100644 --- a/priv/gettext/et/LC_MESSAGES/default.po +++ b/priv/gettext/et/LC_MESSAGES/default.po @@ -9020,7 +9020,7 @@ msgstr "Toiming:" #: lib/phoenix_kit_web/components/media_browser.html.heex:2006 #, elixir-autogen, elixir-format, fuzzy msgid "Add description" -msgstr "Kirjeldus" +msgstr "Lisa kirjeldus" #: lib/phoenix_kit_web/components/media_browser.html.heex:847 #, elixir-autogen, elixir-format, fuzzy @@ -9035,7 +9035,7 @@ msgstr "Märgistatud pisipildid" #: lib/phoenix_kit_web/components/media_browser.html.heex:852 #, elixir-autogen, elixir-format, fuzzy msgid "Archives" -msgstr "Arhiveeritud" +msgstr "Arhiivid" #: lib/phoenix_kit_web/components/media_browser.html.heex:851 #, elixir-autogen, elixir-format @@ -9051,7 +9051,7 @@ msgstr "Tagasi" #: lib/phoenix_kit_web/components/media_browser.html.heex:475 #, elixir-autogen, elixir-format, fuzzy msgid "Background image" -msgstr "Taustapilt (lauaarvuti)" +msgstr "Taustapilt" #: lib/modules/storage/web/settings.html.heex:436 #, elixir-autogen, elixir-format @@ -9077,7 +9077,7 @@ msgstr "Muuda" #: lib/phoenix_kit_web/components/media_browser.html.heex:502 #, elixir-autogen, elixir-format, fuzzy msgid "Choose" -msgstr "Sulge" +msgstr "Vali" #: lib/phoenix_kit/integrations/providers.ex:611 #, elixir-autogen, elixir-format, fuzzy @@ -9093,7 +9093,7 @@ msgstr "Klõpsa **Loo API võti**, anna sellele nimi" #: lib/phoenix_kit_web/live/users/users.html.heex:259 #, elixir-autogen, elixir-format, fuzzy msgid "Close search" -msgstr "Tühjenda" +msgstr "Sulge otsing" #: lib/phoenix_kit/integrations/providers.ex:601 #, elixir-autogen, elixir-format, fuzzy @@ -9113,7 +9113,7 @@ msgstr "Loo ja muuda pildi/video suuruse eelseadeid, mida kasutatakse üleslaadi #: lib/phoenix_kit_web/components/media_browser.html.heex:2362 #, elixir-autogen, elixir-format, fuzzy msgid "Create folder" -msgstr "Loo roll" +msgstr "Loo kaust" #: lib/phoenix_kit_web/components/media_browser.html.heex:709 #, elixir-autogen, elixir-format @@ -9145,7 +9145,7 @@ msgstr "Eemaldatud" #: lib/phoenix_kit_web/components/media_browser.html.heex:850 #, elixir-autogen, elixir-format, fuzzy msgid "Documents" -msgstr "Kommentaarid" +msgstr "Dokumendid" #: lib/phoenix_kit_web/components/media_browser.html.heex:1728 #, elixir-autogen, elixir-format @@ -9212,7 +9212,7 @@ msgstr "Seade uuendamine ebaõnnestus" #: lib/phoenix_kit_web/components/media_browser.html.heex:943 #, elixir-autogen, elixir-format, fuzzy msgid "Filter" -msgstr "Fail" +msgstr "Filter" #: lib/modules/storage/web/settings.html.heex:564 #, elixir-autogen, elixir-format, fuzzy @@ -9222,7 +9222,7 @@ msgstr "Sidumata failid" #: lib/phoenix_kit_web/components/media_browser.html.heex:395 #, elixir-autogen, elixir-format, fuzzy msgid "Folder name" -msgstr "Kaust teisaldatud" +msgstr "Kausta nimi" #: lib/phoenix_kit_web/components/media_browser.ex:1505 #, elixir-autogen, elixir-format @@ -9281,7 +9281,7 @@ msgstr "Ruudustikuvaade" #: lib/phoenix_kit_web/components/media_browser.html.heex:524 #, elixir-autogen, elixir-format, fuzzy msgid "Header size" -msgstr "Pealkiri" +msgstr "Päise suurus" #: lib/phoenix_kit_web/components/core/markdown_editor.ex:192 #, elixir-autogen, elixir-format @@ -9306,7 +9306,7 @@ msgstr "Keeled" #: lib/phoenix_kit_web/components/media_browser.html.heex:530 #, elixir-autogen, elixir-format, fuzzy msgid "Large" -msgstr "Sihtmärk" +msgstr "Suur" #: lib/phoenix_kit_web/components/media_browser.html.heex:843 #, elixir-autogen, elixir-format @@ -9335,17 +9335,17 @@ msgstr "Meedia on põhisüsteemi moodul, mis on alati lubatud ja mida ei saa kee #: lib/phoenix_kit_web/components/media_browser.html.heex:529 #, elixir-autogen, elixir-format, fuzzy msgid "Medium" -msgstr "Meedia" +msgstr "Keskmine" #: lib/phoenix_kit_web/components/media_browser.html.heex:841 #, elixir-autogen, elixir-format, fuzzy msgid "Name A–Z" -msgstr "Nimi" +msgstr "Nimi A–Z" #: lib/phoenix_kit_web/components/media_browser.html.heex:842 #, elixir-autogen, elixir-format, fuzzy msgid "Name Z–A" -msgstr "Nimi" +msgstr "Nimi Z–A" #: lib/phoenix_kit_web/components/media_browser.html.heex:2334 #, elixir-autogen, elixir-format @@ -9365,7 +9365,7 @@ msgstr "Taustata" #: lib/phoenix_kit_web/components/media_browser.html.heex:695 #, elixir-autogen, elixir-format, fuzzy msgid "No description — add one" -msgstr "Kirjeldus puudub" +msgstr "Kirjeldus puudub — lisa see" #: lib/phoenix_kit_web/components/media_browser.ex:2487 #: lib/phoenix_kit_web/components/media_browser.html.heex:1670 @@ -9457,7 +9457,7 @@ msgstr "Näita loomise kuupäeva" #: lib/phoenix_kit_web/components/media_browser.html.heex:562 #, elixir-autogen, elixir-format, fuzzy msgid "Show description" -msgstr "Kirjeldus puudub" +msgstr "Näita kirjeldust" #: lib/phoenix_kit_web/components/media_browser.html.heex:560 #, elixir-autogen, elixir-format @@ -9684,7 +9684,7 @@ msgstr "" #: lib/phoenix_kit_web/components/media_canvas_viewer.html.heex:167 #, elixir-autogen, elixir-format msgid "Play / Pause" -msgstr "" +msgstr "Esita / Peata" #: lib/phoenix_kit/theme_config.ex:122 #, elixir-autogen, elixir-format @@ -9724,7 +9724,7 @@ msgstr "" #: lib/phoenix_kit_web/components/media_canvas_viewer.html.heex:185 #, elixir-autogen, elixir-format, fuzzy msgid "Your browser does not support the audio tag." -msgstr "Teie brauser ei toeta video-elementi." +msgstr "Teie brauser ei toeta heli-elementi." #: lib/phoenix_kit_web/components/user_dashboard_nav.ex:237 #, elixir-autogen, elixir-format, fuzzy From 8bebd53fb9d5fa765120dcd6ee2ef952b97c20ee Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Sun, 19 Jul 2026 00:20:23 +0300 Subject: [PATCH 4/6] Scope the media Trash to the current folder's subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Trash inside a folder showed every root folder's trashed items, not just the folder you were in. Two causes: - The browser passed the embedded scope (nil on /admin/media) to the trash loaders, so at the top level trash was global. A new trash_scope/1 uses the current folder's subtree (falling back to the embed scope, or nil at the true root where "all trash" is correct). Routed every trash path through it — the view, the sidebar badge, and empty_trash, so emptying a folder's trash can't purge sibling roots' files. - list_trashed_folders / count_trashed_folders never subtree-scoped — they only excluded the scope folder itself, leaving trashed folders effectively global. Now restricted to the scope's descendants via folder_subtree_uuids (which walks trashed folders too, so a trashed subfolder's trashed children stay reachable). The file-trash CTE was already correct; it just never got the folder. Tests: storage-level file + folder subtree scoping (folder test verified to fail against the old exclude-self stub), and a browser test that opens trash inside one root and asserts the other root's trashed file is absent (verified to fail against the old nil scope). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MsjUy1HnuJnCSrqdbnANYL --- lib/modules/storage/storage.ex | 48 +++++++++---------- .../components/media_browser.ex | 42 ++++++++++++---- .../components/media_browser_test.exs | 25 ++++++++++ test/integration/storage/scope_test.exs | 38 +++++++++++++++ 4 files changed, 119 insertions(+), 34 deletions(-) diff --git a/lib/modules/storage/storage.ex b/lib/modules/storage/storage.ex index 3b95be315..70ca6fbad 100644 --- a/lib/modules/storage/storage.ex +++ b/lib/modules/storage/storage.ex @@ -1346,36 +1346,36 @@ defmodule PhoenixKit.Modules.Storage do limit = Keyword.get(opts, :limit, 50) offset = Keyword.get(opts, :offset, 0) - query = - from(f in Folder, - where: not is_nil(f.trashed_at), - order_by: [desc: f.trashed_at], - limit: ^limit, - offset: ^offset - ) - - query = - if scope_folder_id do - from(f in query, where: f.uuid != ^scope_folder_id) - else - query - end - - repo().all(query) + from(f in Folder, + where: not is_nil(f.trashed_at), + order_by: [desc: f.trashed_at], + limit: ^limit, + offset: ^offset + ) + |> scope_trashed_folders(scope_folder_id) + |> repo().all() end @doc "Counts trashed folders (with optional scope)." def count_trashed_folders(scope_folder_id \\ nil) do - query = from(f in Folder, where: not is_nil(f.trashed_at), select: count(f.uuid)) + from(f in Folder, where: not is_nil(f.trashed_at), select: count(f.uuid)) + |> scope_trashed_folders(scope_folder_id) + |> repo().one() + |> Kernel.||(0) + end - query = - if scope_folder_id do - from(f in query, where: f.uuid != ^scope_folder_id) - else - query - end + # Restrict trashed folders to the scope folder's own subtree — the folders + # trashed *under* it — so a folder's Trash never shows folders trashed in a + # sibling root. nil scope = all trashed folders (the top-level view). + # `folder_subtree_uuids/1` walks children by parent_uuid without filtering + # trashed_at, so a trashed subfolder's trashed children are still reachable. + # The subtree includes the scope folder itself; drop it — the scope folder + # is where you're standing, not a trashed row. + defp scope_trashed_folders(query, nil), do: query - repo().one(query) || 0 + defp scope_trashed_folders(query, scope_folder_id) do + descendants = folder_subtree_uuids(scope_folder_id) -- [scope_folder_id] + from(f in query, where: f.uuid in ^descendants) end @doc """ diff --git a/lib/phoenix_kit_web/components/media_browser.ex b/lib/phoenix_kit_web/components/media_browser.ex index 9ae31e801..d3a1f989b 100644 --- a/lib/phoenix_kit_web/components/media_browser.ex +++ b/lib/phoenix_kit_web/components/media_browser.ex @@ -441,7 +441,10 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do |> assign(:filter_trash, false) |> assign(:file_view, file_view) |> assign(:orphaned_count, orphaned_count) - |> assign(:trash_count, full_trash_count(scope_folder_id(socket))) + # Badge counts the trash of the folder being navigated to (its subtree), + # matching the scope the Trash view will use — read from the local + # `current_folder`, since the pre-pipe socket still holds the old one. + |> assign(:trash_count, full_trash_count(folder_or_scope(current_folder, scope))) |> assign(:uploaded_files, files) |> assign(:total_count, total_count) |> assign(:total_pages, ceil(total_count / per_page)) @@ -1925,12 +1928,15 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do def handle_event("toggle_trash_filter", _params, socket) do filter_trash = !socket.assigns.filter_trash - scope = scope_folder_id(socket) + # Trash is scoped to the current folder's subtree (see trash_scope/1); + # the active-file reload below uses the plain embedded scope. + t_scope = trash_scope(socket) {files, total_count} = if filter_trash do - load_trashed_files(scope, 1, socket.assigns.per_page) + load_trashed_files(t_scope, 1, socket.assigns.per_page) else + scope = scope_folder_id(socket) folder_uuid = current_folder_uuid(socket) load_scoped_files(scope, 1, socket.assigns.per_page, folder_uuid, "", list_extra(socket)) end @@ -1942,8 +1948,8 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do # cards on screen in the normal view. folders = if filter_trash, - do: Storage.list_trashed_folders(scope), - else: Storage.list_folders(current_folder_uuid(socket), scope) + do: Storage.list_trashed_folders(t_scope), + else: Storage.list_folders(current_folder_uuid(socket), scope_folder_id(socket)) {:noreply, socket @@ -1960,7 +1966,7 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do # would be permanently destroyed. |> assign(:selected_files, MapSet.new()) |> assign(:selected_folders, MapSet.new()) - |> assign(:trash_count, full_trash_count(scope))} + |> assign(:trash_count, full_trash_count(t_scope))} end def handle_event("restore_selected", _params, socket) do @@ -2008,7 +2014,9 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do end def handle_event("empty_trash", _params, socket) do - {:ok, count} = Storage.empty_trash(scope_folder_id(socket)) + # Scoped to the folder you're viewing the Trash of — emptying a folder's + # trash must not purge sibling roots' trashed files. + {:ok, count} = Storage.empty_trash(trash_scope(socket)) {:noreply, socket @@ -2299,7 +2307,7 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do folders = cond do - filter_trash -> Storage.list_trashed_folders(scope) + filter_trash -> Storage.list_trashed_folders(trash_scope(socket)) file_view == "all" -> [] true -> Storage.list_folders(parent_uuid, scope) end @@ -2356,7 +2364,7 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do {files, total_count} = cond do - socket.assigns[:filter_trash] -> load_trashed_files(scope, page, per_page) + socket.assigns[:filter_trash] -> load_trashed_files(trash_scope(socket), page, per_page) socket.assigns.filter_orphaned -> load_orphaned_files(page, per_page) file_view == "all" -> load_all_view_files(scope, page, per_page, search, extra) true -> load_scoped_files(scope, page, per_page, folder_uuid, search, extra) @@ -2377,7 +2385,7 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do |> assign(:uploaded_files, files) |> assign(:total_count, total_count) |> assign(:total_pages, total_pages) - |> assign(:trash_count, full_trash_count(scope_folder_id(socket))) + |> assign(:trash_count, full_trash_count(trash_scope(socket))) |> assign_stacks() end end @@ -2941,6 +2949,20 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do defp scope_folder_id(socket), do: socket.assigns[:scope_folder_id] + # The scope for the Trash view + badge: the folder you're currently in (its + # whole subtree), falling back to the embedded browser scope at the root. + # Keeps a folder's Trash to what was deleted under it — never a sibling + # root's trash. At the true root (no current folder, no embed scope) this is + # nil, so the top-level view still shows all trash. + defp trash_scope(socket), do: current_folder_uuid(socket) || scope_folder_id(socket) + + # trash_scope/1 reads the socket's `current_folder`, but on the nav path the + # target folder is only a local (the socket still holds the old one), so this + # resolves the same "this folder's subtree, else the embed scope" from + # explicit args. + defp folder_or_scope(nil, scope), do: scope + defp folder_or_scope(folder, _scope), do: folder.uuid + defp controlled_mode?(socket), do: socket.assigns[:on_navigate] != nil # ────────────────────────────────────────────────────────────── diff --git a/test/integration/phoenix_kit_web/components/media_browser_test.exs b/test/integration/phoenix_kit_web/components/media_browser_test.exs index 4a159d3ff..04e77a5d8 100644 --- a/test/integration/phoenix_kit_web/components/media_browser_test.exs +++ b/test/integration/phoenix_kit_web/components/media_browser_test.exs @@ -461,6 +461,31 @@ defmodule PhoenixKitWeb.Components.MediaBrowserTest do end end + # --------------------------------------------------------------------------- + # Trash is scoped to the folder you're in — not other roots' trash + # --------------------------------------------------------------------------- + + describe "trash view scoping" do + test "opening trash inside a folder shows only that folder's trash", %{conn: conn} do + {user, _token} = create_admin_user() + r1 = create_folder!(%{name: "root_one"}) + r2 = create_folder!(%{name: "root_two"}) + here = create_file!(r1.uuid) + elsewhere = create_file!(r2.uuid) + {:ok, _} = Storage.trash_file(here) + {:ok, _} = Storage.trash_file(elsewhere) + conn = log_in_user(conn, user) + + # Land inside r1, then open Trash. Before the fix the browser passed a + # nil scope, so trash showed every root's trashed files. + {:ok, view, _html} = live(conn, @media_path <> "?folder=#{r1.uuid}") + html = view |> element("[phx-click='toggle_trash_filter']") |> render_click() + + assert html =~ here.uuid + refute html =~ elsewhere.uuid + end + end + # --------------------------------------------------------------------------- # Info sidebar collapse — viewer-only mode for small screens, per-user sticky # --------------------------------------------------------------------------- diff --git a/test/integration/storage/scope_test.exs b/test/integration/storage/scope_test.exs index 076367b51..c685c33f5 100644 --- a/test/integration/storage/scope_test.exs +++ b/test/integration/storage/scope_test.exs @@ -379,6 +379,44 @@ defmodule PhoenixKit.Integration.Storage.ScopeTest do end end + # --------------------------------------------------------------------------- + # Trash scoping — a folder's Trash shows only its own subtree, never siblings + # --------------------------------------------------------------------------- + + describe "trash scoping" do + test "list/count_trashed_files scoped to a folder covers only its subtree" do + %{scope: scope, grandchild: grandchild, sibling: sibling} = build_tree() + in_scope = create_file!(grandchild.uuid) + out = create_file!(sibling.uuid) + {:ok, _} = Storage.trash_file(in_scope) + {:ok, _} = Storage.trash_file(out) + + scoped = Storage.list_trashed_files(scope.uuid) |> Enum.map(& &1.uuid) + assert in_scope.uuid in scoped + refute out.uuid in scoped + assert Storage.count_trashed_files(scope.uuid) == 1 + + # Unscoped (the true-root view) still sees both. + all = Storage.list_trashed_files() |> Enum.map(& &1.uuid) + assert in_scope.uuid in all + assert out.uuid in all + end + + test "list/count_trashed_folders scoped to a folder covers only its subtree" do + %{scope: scope, grandchild: grandchild, sibling: sibling} = build_tree() + sib_child = create_folder!(%{name: "sib_child", parent_uuid: sibling.uuid}) + {:ok, _} = Storage.trash_folder(grandchild, nil) + {:ok, _} = Storage.trash_folder(sib_child, nil) + + scoped = Storage.list_trashed_folders(scope.uuid) |> Enum.map(& &1.uuid) + assert grandchild.uuid in scoped + refute sib_child.uuid in scoped + # the scope folder itself is where you're standing, not a trashed row + refute scope.uuid in scoped + assert Storage.count_trashed_folders(scope.uuid) == 1 + end + end + # --------------------------------------------------------------------------- # count_orphaned_files/1 # --------------------------------------------------------------------------- From 343db64824cc92a1b3f9784963b5b06887a38b56 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Sun, 19 Jul 2026 16:06:40 +0300 Subject: [PATCH 5/6] Let Esc exit media select mode like the toolbar Cancel In multiple-select mode you could only leave by clicking Cancel. Now Escape exits from anywhere, clearing the selection. A window-keydown is attached to the browser root only while select mode is on (so no global listener otherwise), and both Cancel and Esc route through a shared exit_select_mode/1 helper so they behave identically. Integration test enters select mode and asserts Esc removes the select toolbar. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MsjUy1HnuJnCSrqdbnANYL --- .../components/media_browser.ex | 21 ++++++++++++---- .../components/media_browser.html.heex | 4 ++++ .../components/media_browser_test.exs | 24 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/phoenix_kit_web/components/media_browser.ex b/lib/phoenix_kit_web/components/media_browser.ex index d3a1f989b..c8930edb4 100644 --- a/lib/phoenix_kit_web/components/media_browser.ex +++ b/lib/phoenix_kit_web/components/media_browser.ex @@ -1557,16 +1557,18 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do def handle_event("toggle_select_mode", _params, socket) do if socket.assigns.select_mode do - {:noreply, - socket - |> assign(:select_mode, false) - |> assign(:selected_files, MapSet.new()) - |> assign(:selected_folders, MapSet.new())} + {:noreply, exit_select_mode(socket)} else {:noreply, assign(socket, :select_mode, true)} end end + # Esc leaves select mode from anywhere, mirroring the toolbar's Cancel — the + # window-keydown is only attached while select mode is on (see the heex). + def handle_event("exit_select_mode", _params, socket) do + {:noreply, exit_select_mode(socket)} + end + # Long-press on a card (from the MediaDragDrop JS hook) enters select mode and # selects the held item. def handle_event("long_press_select", %{"type" => "file", "uuid" => uuid}, socket) do @@ -2158,6 +2160,15 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do assign(socket, :selected_files, selected) end + # Leave select mode and drop any selection — shared by the toolbar Cancel + # button and the Esc key. + defp exit_select_mode(socket) do + socket + |> assign(:select_mode, false) + |> assign(:selected_files, MapSet.new()) + |> assign(:selected_folders, MapSet.new()) + end + # Look up the clicked file's enriched map (filename, mime_type, size, urls, # …) inside the current page's uploaded_files list so the modal can render # without an extra DB roundtrip. diff --git a/lib/phoenix_kit_web/components/media_browser.html.heex b/lib/phoenix_kit_web/components/media_browser.html.heex index f0f260679..8bab74f0e 100644 --- a/lib/phoenix_kit_web/components/media_browser.html.heex +++ b/lib/phoenix_kit_web/components/media_browser.html.heex @@ -1,9 +1,13 @@ <%!-- MediaBrowser LiveComponent template. All phx-* events route to the component via phx-target={@myself} on the root div. --%> +<%!-- Esc exits select mode (same as the toolbar Cancel), only while it's on + so the window listener isn't attached otherwise. --%>