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/modules/storage/storage.ex b/lib/modules/storage/storage.ex index 0cef0a848..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 """ @@ -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/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 diff --git a/lib/phoenix_kit_web/components/media_browser.ex b/lib/phoenix_kit_web/components/media_browser.ex index 9ae31e801..00d21ba24 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)) @@ -1554,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 @@ -1869,6 +1874,35 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do # Single-file delete via the per-row kebab menu. Mirrors `delete_selected` # for one file — soft-delete to trash when outside the trash view, permanent # delete when already inside it. Scope-guarded the same way. + # Rotate a single file's saved orientation ±90 from the per-file kebab. + # Same persistence as the viewer's rotate button — writes + # metadata["rotation"] and broadcasts the thumbnail refresh — so the grid + # reorients its thumbnail via the rotation_class CSS transform (no + # re-encode, no open viewer needed). Images only; scope-guarded like the + # other per-file mutations. + def handle_event("rotate_file", %{"file-uuid" => file_uuid, "dir" => dir}, socket) do + delta = if dir == "left", do: -90, else: 90 + scope = scope_folder_id(socket) + + with %Storage.File{} = file <- Storage.get_file(file_uuid), + true <- Storage.within_scope?(file.folder_uuid, scope) do + current = normalized_rotation(Map.get(file.metadata || %{}, "rotation")) + next = Integer.mod(current + delta, 360) + merged = Map.put(file.metadata || %{}, "rotation", next) + + case Storage.update_file(file, %{metadata: merged}) do + {:ok, _} -> + Storage.broadcast_file_thumbnail_updated(file_uuid) + {:noreply, refresh_processed_file(socket, file_uuid, viewer: false)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, gettext("Could not rotate the image."))} + end + else + _ -> {:noreply, socket} + end + end + def handle_event("delete_file", %{"file-uuid" => file_uuid}, socket) do scope = scope_folder_id(socket) repo = PhoenixKit.Config.get_repo() @@ -1925,12 +1959,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 +1979,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 +1997,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 +2045,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 @@ -2150,6 +2189,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. @@ -2299,7 +2347,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 +2404,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 +2425,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 @@ -2635,6 +2683,26 @@ defmodule PhoenixKitWeb.Components.MediaBrowser do icon="hero-arrow-down-tray" label={gettext("Download")} /> + <%!-- Rotate the saved orientation ±90 (images only). Persists like + the viewer; the thumbnail reorients live. --%> + <.table_row_menu_button + :if={@file.file_type == "image"} + phx-click="rotate_file" + phx-target={@myself} + phx-value-file-uuid={@file.file_uuid} + phx-value-dir="left" + icon="hero-arrow-uturn-left" + label={gettext("Rotate left")} + /> + <.table_row_menu_button + :if={@file.file_type == "image"} + phx-click="rotate_file" + phx-target={@myself} + phx-value-file-uuid={@file.file_uuid} + phx-value-dir="right" + icon="hero-arrow-uturn-right" + label={gettext("Rotate right")} + /> <.table_row_menu_button phx-click="prepare_move_file" phx-target={@myself} @@ -2941,6 +3009,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/lib/phoenix_kit_web/components/media_browser.html.heex b/lib/phoenix_kit_web/components/media_browser.html.heex index f0f260679..b83774b04 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. --%>