Skip to content

Release 1.7.99 — merge dev into main#501

Merged
ddon merged 62 commits intomainfrom
dev
Apr 20, 2026
Merged

Release 1.7.99 — merge dev into main#501
ddon merged 62 commits intomainfrom
dev

Conversation

@ddon
Copy link
Copy Markdown
Contributor

@ddon ddon commented Apr 20, 2026

Rolls up everything on dev since the last main update (1.7.95) into a release merge. Hex package already published: https://hex.pm/packages/phoenix_kit/1.7.99 · docs: https://hexdocs.pm/phoenix_kit/1.7.99 · tag: v1.7.99.

Summary

Versions covered: 1.7.96, 1.7.97, 1.7.98, 1.7.99 (see CHANGELOG.md for per-version entries).

Highlights from 1.7.99 (newest)

Included prior versions (already on hex, just catching main up)

Test plan

🤖 Generated with Claude Code

mdon and others added 30 commits April 12, 2026 00:14
- Rewrite custom-admin-pages.md with explicit anti-pattern section covering
  layout loss and cross-live_session navigation failure modes
- Correct claim that dynamic segments require a route module; tab_to_route/1
  splices paths verbatim, so live_view: on a tab supports :id/:slug via
  visible: false hidden tabs (as real modules like posts/catalogue do)
- Correct claim that admin_routes/0 accepts controller/forward/scope; the
  quoted block is spliced inside live_session :phoenix_kit_admin which only
  permits live declarations — redirect non-LiveView routes to generate/1 or
  public_routes/1 with phoenix_kit_sync and phoenix_kit_publishing as refs
- Fix Mix task name (phoenix_kit.gen.admin.page) and argument shape in
  Igniter generator section to match actual parse_args/2 in the task
- Fix LayoutWrapper attr name from url_path to current_path in AGENTS.md
  and remove non-existent current_locale_base example
- Convert tab-path examples to the relative-form convention used by every
  real plugin across ADMIN_README.md, dashboard/README.md, tab.ex moduledoc,
  dashboard.ex, registry.ex, config.ex, tabs_initializer.ex, and the two
  dev_docs guides
- Convert the built-in jobs tab from absolute to relative path form for
  consistency with the rest of the ecosystem
- Add historical-exploration banner to 2025-12-30 routing-architecture guide
  so readers know Strategy 1 was not adopted
- Add cross-session caveat note to PHOENIXKIT_AWS_COMPATIBILITY.md explaining
  the full-page-reload limitation of the override pattern
- Fix Igniter generator After Generation section to stop teaching a
  separate-live_session pattern that recreates the known bug

Motivation: these docs were drifting ahead of the implementation and would
have led module authors into the exact "parent-router hand-registration"
bug class (layout loss + cross-live_session navigation crash) that we just
finished warning against in every other file. Every claim here is now
traceable to a file:line ref in integration.ex, auth.ex, tab.ex, or
layout_wrapper.ex.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The enabled?/0 callback was checking "maintenance_enabled" (whether
maintenance mode is active for end users) instead of
"maintenance_module_enabled" (whether the module is toggled on).
This caused the admin settings page to be inaccessible even after
enabling the module, showing "Maintenance module is not enabled".

Fixes #485

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Align admin routing docs and tab-path conventions with implementation
Add reorder mode toggle to the languages settings page that enables
drag-and-drop reordering of enabled languages using the DraggableList
component. Hide the SortableJS fallback clone at the initial position
to prevent a ghost chip visual artifact during drag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the CSS fix for hiding the SortableJS fallback clone from the
languages template into the SortableGrid hook so any draggable_list
with hide_source={true} gets the behavior automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added feature to make languages sortable in the language admin page
- Move wiggle keyframes from inline <style> in heex into phoenix_kit.js
  and phoenix_kit_sortable.js injectStyles(); rename class to
  pk-sortable-wiggle and respect prefers-reduced-motion.
- Dedup ordered_codes in Languages.reorder_languages/1 (Enum.uniq)
  and use MapSet for the remaining filter.
- Add reorder_languages/1 integration tests covering reorder, partial
  list, unknown codes, empty list, and duplicate codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Refactor maintenance mode with layout override and scheduled windows

Replaces the old @show_maintenance assign approach with a dynamic layout
swap via socket.private[:live_layout] — the underlying LiveView keeps
running so form state and scroll position are preserved when maintenance
toggles on or off. URL never changes.

Core changes:
- Layout override in on_mount hook instead of redirect. When maintenance
  turns on, put_in socket.private[:live_layout] swaps the layout live;
  when it ends, PubSub triggers restoration of the original layout
- New PhoenixKitWeb.Layouts :maintenance template with countdown timer
- HTTP plug renders inline 503 HTML (with Retry-After header) for
  controller routes, with proper Phoenix.HTML escaping to prevent XSS
- Scheduled maintenance windows with start/end UTC datetimes, 1-year
  upper bound, and 60-second tolerance for datetime-local minute precision
- cleanup_expired_schedule auto-disables stale state on every page access
- Process.send_after timer unblocks users when scheduled end arrives
  (clamped to Erlang's 32-bit timeout limit)
- PubSub broadcasts on every state change so all connected LiveViews
  react instantly; admin tabs stay put, user tabs swap layouts
- Manual toggle clears expired schedule on enable to avoid stale locks
- Activity logging for all admin actions (toggle, content, schedule)
- All user-facing strings wrapped in gettext

Schedule validation rejects: empty, past start/end, end before start,
dates >1 year in the future. Datetime inputs use the system time_zone
setting for display and convert to UTC for storage.

Extracts timezone helpers (offset_to_seconds, shift_to_offset,
parse_datetime_local, format_datetime_local) to PhoenixKit.Utils.Date
so they can be tested in isolation.

Adds 93 new tests: unit tests for validate_schedule and PubSub,
integration tests for Maintenance context and the plug (including
XSS regression test), and doctested timezone helpers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix maintenance settings: live preview + remove broken Preview button

- Move phx-change from individual inputs to the form element so the
  live preview updates on every keystroke (input-level phx-change on
  text inputs only fires on blur, making the preview appear broken)
- Remove the "Preview" link that navigated to /maintenance. The path
  went through locale-prefixed routes and got caught by the publishing
  module's /:language/:group catch-all. The settings page already has
  an inline Live Preview card rendering the same content, so the link
  was redundant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review feedback on maintenance refactor

HIGH (merge blockers):
- Register MaintenanceCountdown hook in priv/static/assets/phoenix_kit.js
  alongside the other phoenix_kit hooks. Parent apps already include this
  file, so the hook is now reliably available (was previously injected by
  a plug script that wasn't guaranteed to run before LiveSocket init).
  Remove the fragile inline injection from the Integration plug.
- Plug's 503 HTML now uses inline CSS instead of linking to
  /assets/css/app.css (which wasn't actually served — the real digested
  path is /assets/app.css). The page is now self-contained with light +
  dark mode support via prefers-color-scheme, so it works on any route
  regardless of the parent app's asset pipeline.
- Replace String.contains?/2 with String.starts_with?/2 in the plug's
  auth_route?/1 and static_asset?/1. A parent-app path like
  /blog/users/log-in-to-us would have bypassed maintenance mode. Add a
  regression test covering parent-app look-alike paths.

MEDIUM:
- disable_system/0 now clears maintenance_scheduled_end in addition to
  maintenance_scheduled_start so a stale end time doesn't surprise-disable
  the next re-enable. Update the test to assert both fields are cleared.
- Track the Process.send_after timer ref in socket assigns and cancel it
  on reschedule (via new reschedule_maintenance_end_timer/1) so schedule
  changes don't leave a stale "auto-off" signal in flight.
- Settings LiveView's PubSub handler now re-reads header and subtext so
  multi-admin editing stays in sync. The save handler broadcasts status
  change to trigger this sync.
- schedule_error_message/1 catch-all now logs a warning with the unknown
  atom so future validation additions surface instead of being silently
  swallowed.
- Document check_maintenance_mode/1's required call sites (all 6 on_mount
  hooks listed in the @doc) so new live_sessions don't forget it.
- Add a HACK comment near put_in socket.private[:live_layout] noting it
  relies on Phoenix LiveView internals (same pattern as
  maybe_apply_plugin_layout) and should be revisited on major LV upgrades.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fold check_maintenance_mode into shared scope-mount helper

Addresses the final MEDIUM improvement from PR review: instead of each
of the 6 on_mount hooks explicitly calling check_maintenance_mode/1,
fold it into mount_phoenix_kit_current_scope/3 which all 6 already use.

New live_sessions that use a scope-mounting on_mount hook now inherit
maintenance mode enforcement automatically — no way to forget it.

Removes 6 redundant call sites and updates the @doc comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Refactor maintenance mode with layout override and scheduled windows

Replaces the old @show_maintenance assign approach with a dynamic layout
swap via socket.private[:live_layout] — the underlying LiveView keeps
running so form state and scroll position are preserved when maintenance
toggles on or off. URL never changes.

Core changes:
- Layout override in on_mount hook instead of redirect. When maintenance
  turns on, put_in socket.private[:live_layout] swaps the layout live;
  when it ends, PubSub triggers restoration of the original layout
- New PhoenixKitWeb.Layouts :maintenance template with countdown timer
- HTTP plug renders inline 503 HTML (with Retry-After header) for
  controller routes, with proper Phoenix.HTML escaping to prevent XSS
- Scheduled maintenance windows with start/end UTC datetimes, 1-year
  upper bound, and 60-second tolerance for datetime-local minute precision
- cleanup_expired_schedule auto-disables stale state on every page access
- Process.send_after timer unblocks users when scheduled end arrives
  (clamped to Erlang's 32-bit timeout limit)
- PubSub broadcasts on every state change so all connected LiveViews
  react instantly; admin tabs stay put, user tabs swap layouts
- Manual toggle clears expired schedule on enable to avoid stale locks
- Activity logging for all admin actions (toggle, content, schedule)
- All user-facing strings wrapped in gettext

Schedule validation rejects: empty, past start/end, end before start,
dates >1 year in the future. Datetime inputs use the system time_zone
setting for display and convert to UTC for storage.

Extracts timezone helpers (offset_to_seconds, shift_to_offset,
parse_datetime_local, format_datetime_local) to PhoenixKit.Utils.Date
so they can be tested in isolation.

Adds 93 new tests: unit tests for validate_schedule and PubSub,
integration tests for Maintenance context and the plug (including
XSS regression test), and doctested timezone helpers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix maintenance settings: live preview + remove broken Preview button

- Move phx-change from individual inputs to the form element so the
  live preview updates on every keystroke (input-level phx-change on
  text inputs only fires on blur, making the preview appear broken)
- Remove the "Preview" link that navigated to /maintenance. The path
  went through locale-prefixed routes and got caught by the publishing
  module's /:language/:group catch-all. The settings page already has
  an inline Live Preview card rendering the same content, so the link
  was redundant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review feedback on maintenance refactor

HIGH (merge blockers):
- Register MaintenanceCountdown hook in priv/static/assets/phoenix_kit.js
  alongside the other phoenix_kit hooks. Parent apps already include this
  file, so the hook is now reliably available (was previously injected by
  a plug script that wasn't guaranteed to run before LiveSocket init).
  Remove the fragile inline injection from the Integration plug.
- Plug's 503 HTML now uses inline CSS instead of linking to
  /assets/css/app.css (which wasn't actually served — the real digested
  path is /assets/app.css). The page is now self-contained with light +
  dark mode support via prefers-color-scheme, so it works on any route
  regardless of the parent app's asset pipeline.
- Replace String.contains?/2 with String.starts_with?/2 in the plug's
  auth_route?/1 and static_asset?/1. A parent-app path like
  /blog/users/log-in-to-us would have bypassed maintenance mode. Add a
  regression test covering parent-app look-alike paths.

MEDIUM:
- disable_system/0 now clears maintenance_scheduled_end in addition to
  maintenance_scheduled_start so a stale end time doesn't surprise-disable
  the next re-enable. Update the test to assert both fields are cleared.
- Track the Process.send_after timer ref in socket assigns and cancel it
  on reschedule (via new reschedule_maintenance_end_timer/1) so schedule
  changes don't leave a stale "auto-off" signal in flight.
- Settings LiveView's PubSub handler now re-reads header and subtext so
  multi-admin editing stays in sync. The save handler broadcasts status
  change to trigger this sync.
- schedule_error_message/1 catch-all now logs a warning with the unknown
  atom so future validation additions surface instead of being silently
  swallowed.
- Document check_maintenance_mode/1's required call sites (all 6 on_mount
  hooks listed in the @doc) so new live_sessions don't forget it.
- Add a HACK comment near put_in socket.private[:live_layout] noting it
  relies on Phoenix LiveView internals (same pattern as
  maybe_apply_plugin_layout) and should be revisited on major LV upgrades.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fold check_maintenance_mode into shared scope-mount helper

Addresses the final MEDIUM improvement from PR review: instead of each
of the 6 on_mount hooks explicitly calling check_maintenance_mode/1,
fold it into mount_phoenix_kit_current_scope/3 which all 6 already use.

New live_sessions that use a scope-mounting on_mount hook now inherit
maintenance mode enforcement automatically — no way to forget it.

Removes 6 redundant call sites and updates the @doc comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add automatic integration health tracking on OAuth refresh

Token refresh failures used to be logged and forgotten — the admin UI
continued to show the integration as "connected" until they manually
clicked Test Connection. Now:

- Integrations.record_validation/3 is the single source of truth for
  writing status + validation_status + last_validated_at, broadcasting
  integration_validated PubSub events, and no-oping when nothing changed.
- refresh_access_token/1 calls it on both success (auto-recovering from
  a previously-errored state) and failure.
- Activity entries integration.token_refresh_failed and
  integration.auto_recovered added.
- IntegrationForm.save_validation_result/3 deleted; Test Connection now
  uses the same helper.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow-up to #492. Three related issues found in review:

- record_validation/3 silently no-oped when called with a settings-row
  UUID (e.g. from authenticated_request -> refresh_access_token via
  external modules that hold UUIDs). The automatic failure path then
  never flipped the admin UI, defeating the PR's stated goal.
  Resolves the key once via resolve_storage/1 which handles both
  "provider:name" keys and UUIDs, returning the canonical storage key
  plus decrypted data.
- The actor_uuid parameter was ignored. Dropped to arity 2 — the
  manual path already attributes activity via validate_connection/2,
  and the two automatic activity entries are correctly auto-attributed.
- Events.broadcast_validated fired even when save_integration failed.
  Guarded behind {:ok, _} so subscribers don't render stale state.

Also adds integration tests for record_validation covering success,
error formatting, no-op on unchanged status, missing-row case, and
the UUID path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…_cat_items (#493)

* Refactor maintenance mode with layout override and scheduled windows

Replaces the old @show_maintenance assign approach with a dynamic layout
swap via socket.private[:live_layout] — the underlying LiveView keeps
running so form state and scroll position are preserved when maintenance
toggles on or off. URL never changes.

Core changes:
- Layout override in on_mount hook instead of redirect. When maintenance
  turns on, put_in socket.private[:live_layout] swaps the layout live;
  when it ends, PubSub triggers restoration of the original layout
- New PhoenixKitWeb.Layouts :maintenance template with countdown timer
- HTTP plug renders inline 503 HTML (with Retry-After header) for
  controller routes, with proper Phoenix.HTML escaping to prevent XSS
- Scheduled maintenance windows with start/end UTC datetimes, 1-year
  upper bound, and 60-second tolerance for datetime-local minute precision
- cleanup_expired_schedule auto-disables stale state on every page access
- Process.send_after timer unblocks users when scheduled end arrives
  (clamped to Erlang's 32-bit timeout limit)
- PubSub broadcasts on every state change so all connected LiveViews
  react instantly; admin tabs stay put, user tabs swap layouts
- Manual toggle clears expired schedule on enable to avoid stale locks
- Activity logging for all admin actions (toggle, content, schedule)
- All user-facing strings wrapped in gettext

Schedule validation rejects: empty, past start/end, end before start,
dates >1 year in the future. Datetime inputs use the system time_zone
setting for display and convert to UTC for storage.

Extracts timezone helpers (offset_to_seconds, shift_to_offset,
parse_datetime_local, format_datetime_local) to PhoenixKit.Utils.Date
so they can be tested in isolation.

Adds 93 new tests: unit tests for validate_schedule and PubSub,
integration tests for Maintenance context and the plug (including
XSS regression test), and doctested timezone helpers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix maintenance settings: live preview + remove broken Preview button

- Move phx-change from individual inputs to the form element so the
  live preview updates on every keystroke (input-level phx-change on
  text inputs only fires on blur, making the preview appear broken)
- Remove the "Preview" link that navigated to /maintenance. The path
  went through locale-prefixed routes and got caught by the publishing
  module's /:language/:group catch-all. The settings page already has
  an inline Live Preview card rendering the same content, so the link
  was redundant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review feedback on maintenance refactor

HIGH (merge blockers):
- Register MaintenanceCountdown hook in priv/static/assets/phoenix_kit.js
  alongside the other phoenix_kit hooks. Parent apps already include this
  file, so the hook is now reliably available (was previously injected by
  a plug script that wasn't guaranteed to run before LiveSocket init).
  Remove the fragile inline injection from the Integration plug.
- Plug's 503 HTML now uses inline CSS instead of linking to
  /assets/css/app.css (which wasn't actually served — the real digested
  path is /assets/app.css). The page is now self-contained with light +
  dark mode support via prefers-color-scheme, so it works on any route
  regardless of the parent app's asset pipeline.
- Replace String.contains?/2 with String.starts_with?/2 in the plug's
  auth_route?/1 and static_asset?/1. A parent-app path like
  /blog/users/log-in-to-us would have bypassed maintenance mode. Add a
  regression test covering parent-app look-alike paths.

MEDIUM:
- disable_system/0 now clears maintenance_scheduled_end in addition to
  maintenance_scheduled_start so a stale end time doesn't surprise-disable
  the next re-enable. Update the test to assert both fields are cleared.
- Track the Process.send_after timer ref in socket assigns and cancel it
  on reschedule (via new reschedule_maintenance_end_timer/1) so schedule
  changes don't leave a stale "auto-off" signal in flight.
- Settings LiveView's PubSub handler now re-reads header and subtext so
  multi-admin editing stays in sync. The save handler broadcasts status
  change to trigger this sync.
- schedule_error_message/1 catch-all now logs a warning with the unknown
  atom so future validation additions surface instead of being silently
  swallowed.
- Document check_maintenance_mode/1's required call sites (all 6 on_mount
  hooks listed in the @doc) so new live_sessions don't forget it.
- Add a HACK comment near put_in socket.private[:live_layout] noting it
  relies on Phoenix LiveView internals (same pattern as
  maybe_apply_plugin_layout) and should be revisited on major LV upgrades.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fold check_maintenance_mode into shared scope-mount helper

Addresses the final MEDIUM improvement from PR review: instead of each
of the 6 on_mount hooks explicitly calling check_maintenance_mode/1,
fold it into mount_phoenix_kit_current_scope/3 which all 6 already use.

New live_sessions that use a scope-mounting on_mount hook now inherit
maintenance mode enforcement automatically — no way to forget it.

Removes 6 redundant call sites and updates the @doc comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add V97 migration: per-item markup_percentage override on phoenix_kit_cat_items

Adds a nullable `markup_percentage DECIMAL(7, 2)` column on
`phoenix_kit_cat_items`. When `NULL`, pricing falls back to the
parent catalogue's `markup_percentage` (existing behavior); when set
(including `0`), the item uses its own value.

`NULL` vs. `0` is load-bearing: `0` means "explicitly sell at base
price", `NULL` means "inherit whatever the catalogue currently uses".
The column is nullable with no default, matching that distinction.
Existing rows stay `NULL` and continue to inherit — no backfill is
needed.

The `up/1` operation is idempotent (`IF NOT EXISTS` guard inside a
`DO $$` block), and the `down/1` rollback uses `DROP COLUMN IF
EXISTS` so it's also idempotent. Lossy rollback note included in
the down/1 docstring: any per-item overrides set after V97 are lost
on rollback (items revert to the catalogue's markup).

PG `ALTER TABLE ADD COLUMN` with no default and nullable = metadata-
only operation, no full-table rewrite, AccessExclusive lock held
briefly — safe for production-size tables.

No new index — `markup_percentage` isn't queried as a filter, only
SELECTed and computed in app code.

Bumps `@current_version` to 97 and adds the `### V97` docblock entry
above V96 (with the `⚡ LATEST` marker moved).

Used by `phoenix_kit_catalogue` to support a per-item markup override
field on the item form and import wizard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Drop language_switcher_dropdown from admin top bar in layout_wrapper.ex;
  locale selection now lives in the user avatar dropdown only
- Remove dead admin_language_dropdown/1 and unused Phoenix.LiveView.JS alias
  from admin_nav.ex
- Update Languages README: clarify that admin locale switches via user
  menu, and the pre-login globe on the sign-in page stays for
  unauthenticated visitors
- Extend table_default_with_cards with toolbar_title and toolbar_actions
  slots, rendered in the same row as the view-toggle buttons. Toolbar row
  is only shown when at least one of the three (title, actions, toggle)
  is present. Flex-wrap layout keeps the row adaptive on mobile; the
  view toggle stays desktop-only since mobile forces card view.

- Apply to roles page: move 'Create New Role' button from a centered
  block above the table into toolbar_actions; add role count in
  toolbar_title.

- Apply to activity page: move 'Clear filters' button from the filter
  card into toolbar_actions; show total count in toolbar_title and
  drop duplicate count from page subtitle.

- Apply to integrations page: move 'Add Integration' from the page
  header actions into toolbar_actions; add connection count in
  toolbar_title. Empty-state CTA already provides its own button.
…#490)

* Fix V95 migration to be truly idempotent for folder_uuid column

Replace Ecto add_if_not_exists with raw SQL DO block that checks
information_schema before adding the column, avoiding FK constraint
conflicts when the migration runs against an existing schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add UUID search support to media page search bar

Allow searching media files by UUID in addition to file name, enabling
quick lookup of files by their identifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix long text overflow in media detail sidebar

Add break-all and overflow constraints to the filename, title, and
description fields in the media detail info panel to prevent long
unbroken strings from overflowing the sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add variant dimensions and file sizes to media detail page

Display width×height and file size on each variant download button
so users can quickly see the size details of each variant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix missing original file size in variant download buttons

Fall back to the main file record's size and dimensions when the
original file instance lacks them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add multi-format variant generation and image_set component

Add alternative_formats field to storage dimensions so admins can
configure additional output formats (WebP, AVIF) per dimension. The
variant generator creates extra file instances for each alternative
format alongside the primary.

Add <.image_set> component that renders a <picture> element with
<source> tags per format and srcset with width descriptors, letting
browsers pick the best supported format and optimal size.

- V97 migration adds alternative_formats column
- Dimension schema validates alternative formats
- Variant generator expands dimensions with alternatives
- Admin UI: checkbox group on dimension form, badges on list
- VariantNaming utility for parsing variant names
- Storage helpers for bulk-loading variant data
- Component groups by actual mime_type to handle failed conversions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add cross-user file deduplication with safe shared deletion

When a different user uploads a file with the same checksum, clone
the File record and instances to reuse the existing storage path
instead of re-storing. On deletion, only remove physical files when
no other File records share the same path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add All Files view and rename root button on media page

Add an All Files button in the sidebar that shows every file across
all folders in a flat grid/list without folder cards. Rename the
previous All Files button to Root for clarity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix uploaded media going to root instead of current folder

Pass the current folder UUID when reloading files after upload so
the view stays in the current folder and new files appear there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review feedback for image-set and alternative formats

- Fix stray separator in dimensions table format display
- Move alternative_formats template computation into LiveView assigns
- Omit PNG from <picture><source> entries (keep as <img> fallback)
- Guard empty srcset case when fallback_variants is empty
- Bump migration from V97 to V98 with V97 as reserved placeholder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Alexander Don <alexdon20022@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Bump version 1.7.96 → 1.7.97
- CHANGELOG: remove duplicate 1.7.96 block, add 1.7.97 entry covering V97
  per-item markup, V98 alternative_formats, ImageSet component, VariantNaming,
  multi-format variants, V95 idempotency fix, UI polish
- Add PR #490 follow-up review section confirming all prior findings resolved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract Users.Media LiveView into reusable PhoenixKitWeb.Components.MediaBrowser
accepting optional scope_folder_id for hard-scoped embedding (variant A).
/admin/media behavior preserved byte-for-byte (scope=nil).

Storage:
- Add within_scope?/2 predicate and scope-aware helpers:
  list_folder_tree/1, folder_breadcrumbs/2, list_folders/2,
  list_files_in_scope/2 (recursive CTE), count_orphaned_files/1
- Scope guards on all mutators (create_folder, update_folder, delete_folder,
  move_file_to_folder, create_folder_link) return {:error, :out_of_scope}

Component:
- Parent LiveView shrunk to wrapper (1158 LOC to 38 LOC)
- Upload via progress: callback replaces Process.send_after polling
  (handle_info absent, incompatible with live_components)
- Controlled mode detected by on_navigate attr presence: parent owns
  navigation state and receives {MediaBrowser, id, {:navigate, params}}
  notify messages; push_patch round-trip for ?folder/?q/?page/?orphaned
- scope_invalid detection with UI banner when scope folder deleted
- Upload target falls back to scope_folder_id at virtual root
- Orphan filter UI hidden when scope is set
- scoped_fallback? flash on URL-hack to out-of-scope folder
- Integer.parse fallback for malformed ?page param
- Guard update/2 first-mount check via Map.has_key? (not raises on nil)
- assign_new(:scope_folder_id) default so template @scope_folder_id
  renders safely when parent omits the attr

Tests (52 total):
- 18 URL-builder unit tests (media_url_test.exs)
- 17 LiveView integration tests for URL sync and auth (media_test.exs)
- 17 component tests for scope behaviors (media_browser_test.exs)
- Storage scope contracts (scope_test.exs, media_browser_scope_test.exs)
- Extended ConnCase with sandbox + endpoint supervision

Out of scope (Task 7 follow-up):
- attr declarations on component
- Inline <script>/<style> extraction from template
- Bulk-mutator error aggregation for partial out-of-scope failures
- Extract build_url/parse_page as public helpers (test duplication)
Merge upstream/dev (commits a470bea..5f19ab8) which adds V97/V98
migrations, maintenance mode, OAuth health check, image_set component,
and a new ?view=all "All Files" feature in the media browser.

Conflicts in media.ex and media.html.heex resolved with our refactor
(MediaBrowser live_component), then upstream's ?view=all feature ported
into the new architecture:

- Add :file_view assign and navigate_view_all event to MediaBrowser
- Extend Storage.list_files_in_scope/2 with UUID-search via
  `fragment("CAST(? AS TEXT) ILIKE ?", f.uuid, search)`
- URL round-trip for ?view=all (parent handle_params ↔ component
  navigate/apply_nav_params); search and clear_search now preserve
  file_view; toggle_orphan_filter explicitly resets view to nil
- Template: All Files sidebar button with active-state highlight,
  All Files ({count}) section title, empty state, hide folder rows
  and new-folder controls when file_view == "all"
- Preserves scope semantics: view=all under scope shows all files
  within scope subtree via existing recursive CTE (no leakage)

Tests added: 2 URL-builder tests (view=all + q roundtrip, view=all
standalone), 2 UUID-search tests in scope_test.exs, 1 deep-link test
for /admin/media?view=all.

Refactor bonus: resolve_folder/2 and load_nav_files/7 extracted from
apply_nav_params to reduce complexity.
Runtime fixes discovered during browser smoke testing on Decor3D Print.

MediaBrowser event routing:
- Add phx-target={@Myself} to every phx-click/submit/change/keydown binding
  in media_browser.html.heex (57 occurrences) and function components
  folder_tree_node/move_folder_option (8 occurrences). phx-target does NOT
  cascade from the root div; each event element needs its own target.
  Without this, all clicks routed to the parent LiveView and crashed with
  UndefinedFunctionError since the parent only defines mount/render.
- Add :myself attr to folder_tree_node and move_folder_option function
  components so recursive calls propagate it correctly.

MediaBrowser KeyError on first mount:
- Replace `not socket.assigns[:uploaded_files]` with `not Map.has_key?(...)`
  in update/2. Elixir's `not` is strict-boolean and raises ArgumentError
  on nil or list values.
- Add assign_new(:scope_folder_id, fn -> nil end) so templates that use
  @scope_folder_id don't crash with KeyError when parent omits the attr.

AssetsController — serve phoenix_kit_consent.js:
- Extend the valid-assets map to route by OTP app, not just filename.
  phoenix_kit_consent.js lives in the phoenix_kit_legal package;
  previously the controller returned 404 text/plain, breaking the strict
  MIME check on any page that injects the consent banner script (that
  then also broke other <script> tags and in effect killed LiveSocket
  init on many pages).
- delete_selected: check within_scope? for each file before calling
  delete_file_completely. Previously scope was read but not applied,
  allowing crafted WebSocket events to delete files outside scope.
- navigate_to_folder (uncontrolled mode): add within_scope? guard
  before rendering folder contents. Previously a crafted navigate_folder
  event with an out-of-scope UUID could expose sibling folder names.

Both are defense-in-depth fixes — admin-only, require crafted events,
but the scope enforcement contract should hold uniformly across all
code paths. Found during independent PR review by verifier agent.
Add MediaBrowser live_component with scope_folder_id
Covers scope enforcement across all mutators, event routing verification
(62 bindings), AssetsController widening for Legal app, and follow-up on
commit 4a7057d closing two defense-in-depth holes in delete_selected
and navigate_to_folder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Alexander Don and others added 29 commits April 16, 2026 16:54
Show "Trash is empty" with appropriate icon and hint instead of the
generic upload prompt when viewing an empty trash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reset filter_trash and filter_orphaned in navigate_to_folder and
navigate_view_all so switching views doesn't carry over stale state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reset filter_trash in apply_nav_params so controlled mode navigation
(All Files, Root, folders) properly exits trash view without requiring
a page refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass initial URL params to MediaBrowser on first mount so it loads
the correct view immediately instead of defaulting to root and then
correcting after WebSocket connects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove show_upload call from FolderDropUpload hook so files inject
directly into the hidden upload input. Add inline progress bars in
the card body. Fix upload input selector to search parent content
area instead of the card-body scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changes requested. Flags two critical scope-isolation holes in trash
view (list_trashed_files/count_trashed_files ignore scope) and permanent
delete from trash (no within_scope? guard), plus PruneTrashJob defined
but never scheduled in any crontab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. CRITICAL: Scope trash queries — list_trashed_files, count_trashed_files,
   and empty_trash now accept scope_folder_id and use recursive CTE to
   constrain results to the scope subtree.

2. CRITICAL: Permanent delete from trash now checks within_scope? before
   calling delete_file_completely, matching the soft-delete branch.

3. HIGH: Wire PruneTrashJob into Oban cron config in installer so parent
   apps pick up the daily 3 AM cleanup schedule.

4. HIGH: Add trashed_at to @type t spec and trashed to Status Flow docs.

5. Filter trashed files from Storage.list_files/1 for external callers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Updated media browser component
…review

restore_selected iterated client-trusted UUIDs from the selected_files
MapSet and called Storage.restore_file/1 without a within_scope? check,
mirroring the class of bug the PR #497 follow-up commit fixed for the
permanent-delete branch. A scoped embed could push any UUID via
toggle_select and restore files outside its scope.

Apply the same repo.get + within_scope? guard the delete branch uses,
and switch the flash to the actually-restored count since the guard
may skip some selections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uard

- Bump version 1.7.97 → 1.7.98
- CHANGELOG 1.7.98 entry covering PR #497 (V99 trash migration, PruneTrashJob
  daily cron, drag-drop upload via FolderDropUpload hook, URL-param first-mount
  hydration) and the restore_selected scope-guard fix

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
V100 creates the four staff tables (`phoenix_kit_staff_departments`,
`phoenix_kit_staff_teams`, `phoenix_kit_staff_people`,
`phoenix_kit_staff_team_memberships`) used by the new `phoenix_kit_staff`
module — UUIDv7 PKs, cascading deletes dept → team → memberships and
user → person → memberships.

V101 creates the five projects tables (`phoenix_kit_project_tasks`,
`phoenix_kit_project_task_dependencies`, `phoenix_kit_projects`,
`phoenix_kit_project_assignments`, `phoenix_kit_project_dependencies`)
used by `phoenix_kit_projects`. Assignment/task templates carry a
polymorphic assignee (team/department/person) and a
`CHECK (num_nonnulls(...) <= 1)` constraint enforces at-most-one assignee
on both tables. V101 depends on V100 for the assignee FKs.

Bumps @current_version 99 → 101 and extends the moduledoc changelog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add V100 staff tables and V101 projects tables migrations
Move project logo from authorization to main settings page. Add site
icon (favicon) setting with browser tab preview mockup. Add default
tab title setting for browser tab fallback text. Layout wrapper reads
both settings to render dynamic favicon and tab title.

Also: fix recursive CTE in scoped trash query, use scope folder name
instead of hardcoded "Root" in MediaBrowser, remove file counts from
content area headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix scope guard gap in restore_selected and append PR #497 follow-up review

Address PR review: make MediaBrowser uploads work in embedded components

Parent LiveViews embedding MediaBrowser must register uploads on their
own socket since LiveView routes upload channel events to the parent.
Add setup_uploads/1 helper and handle_parent_info/2 catch-all. The
component tracks its id with the parent via :register_component
message, and uploads are routed back to the component via send_update
with a :pending_upload key so folder placement uses the component's
current state.

- Hide All Files button when scoped (only on admin page)
- Show scope folder name instead of "Root" in sidebar/header
- Remove file count from header titles
- Bypass scope check on initial upload placement (files start at root)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove phx-target from the hidden upload form so validate events route
to the parent LiveView where uploads are registered. Enable the
FolderDropUpload hook whenever buckets are available (not only when in
a subfolder). Drop a file anywhere in the media browser to auto-upload
with inline progress bars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hide the search bar by default behind a magnifying glass icon button
in the header. Click to expand the search input, click again to
collapse. The bar stays visible when a search query is active, and
the toggle button highlights to indicate the active state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Attach MediaDragDrop hook to the component root so existing
data-draggable-file and data-drop-folder attributes on files/folders
become functional. Use pushEventTo(self.el) to route the move event
to the component instead of the parent LiveView.

Fix FolderDropUpload hook to ignore internal drags by checking for
"Files" in dataTransfer.types, so device uploads and folder moves
don't interfere.

Change scoped root query to show only direct children (not the full
subtree) so files moved into subfolders visually disappear from the
root view. Searches still walk the full subtree via recursive CTE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap sidebar and content area in a shared card with rounded corners
and shadow so they look like one component instead of separate pieces.
Remove the inner content card's shadow, add left padding to the
content area for breathing room, and hide the "Folders" sidebar
header in scoped embeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed the `[data-media-view]` display rules and the pre-render script
that synced `html.dataset.mediaView` from localStorage. The server now
conditionally renders only one of the grid or list view based on
@view_mode, so those CSS rules could incorrectly hide the list view when
localStorage held a stale value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inline "Folder name" input at the scope root was indented less than
the folder rows above it because it lacked the chevron spacer and used a
different outer gap. Added the w-5 spacer, matched gap-0.5 on the form,
and set ml-1.5 before the input so icons line up vertically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click on a file now only pushes to /admin/media/:uuid when the component
is rendered with admin={true}. In any other embedding the click toggles
the file into the selection and switches select_mode on, so the component
acts as a picker by default. The admin media page opts in explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Selection header gains a `…` dropdown with Download and Delete. Download
pushes a `download_files` client event consumed by the MediaDragDrop hook,
which fires one staggered `<a download>` click per file. Delete covers
both files (trashed) and folders (removed) in a single action, with a
context-aware confirmation built by `delete_selected_confirm/3`.

Introduces `PhoenixKitWeb.Components.MediaBrowser.Embed` so embedding the
browser is a single `use` line on the parent LiveView. The macro attaches
an `on_mount` that calls `setup_uploads/1` and injects the `"validate"`
upload-channel stub and `handle_info` delegator via `@before_compile`, so
user clauses for other events/messages still match first. `users/media.ex`
is migrated to the macro as the reference caller.

AGENTS.md gets a MediaBrowser Component section covering the one-line
embed, the `parent_uploads` template requirement, the `admin` attr, the
built-in selection actions, and the manual-wiring fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fully-qualified PhoenixKitWeb.Components.MediaBrowser references
inside __before_compile__ are intentional: the quote is injected into
the caller's module, where any alias declared in Embed wouldn't be in
scope. Suppress the Credo suggestion locally and leave a note about why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- V102: `kind` + `discount_percentage` on catalogues, `discount_percentage` +
  `default_value` / `default_unit` on items, new
  `phoenix_kit_cat_item_catalogue_rules` table with CHECK constraints,
  UNIQUE (item_uuid, referenced_catalogue_uuid), and ON DELETE CASCADE on
  both FKs. Idempotent up/down; partial index on `kind='smart'`.
- MultilangForm: trailing-debounce `attach_hook/4` + client-side skeleton
  toggle via composed JS. Rapid language-tab clicks no longer flash stale
  content or re-mount non-translatable fields.
- Core form components (input/select/textarea/checkbox): realign `class`
  attr so it merges onto the styled element (input / daisyUI select wrapper
  / textarea / checkbox). `<.input>` gains `wrapper_class` for the outer
  `phx-feedback-for` div — matches the Phoenix 1.7 generator convention.
- AGENTS.md: document the Core Form Components and Multilang Form
  Components sections, including the wrapper-scope rule and why language
  switching is client-side-first.
- Bump to 1.7.99 and add test_load_filters / test_ignore_filters for
  Elixir 1.19 mix test hygiene.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updated the media browser component
Add V102 smart catalogues migration and multilang debounce flow
Two coupled changes to the parent → component pending-upload handoff:

- The Embed macro's `handle_info({Mod, _, _}, socket)` clause only matches
  3-tuples, but the pending-upload message was a 4-tuple so the clause
  silently skipped it. Wrap the payload as `{path, entry}` to keep the
  outer message 3-tuple.
- When more than one MediaBrowser is mounted on the same page, the first
  `send_update` recipient would consume (and delete) the shared temp
  file, and subsequent recipients would crash on `File.stat/1`. Copy the
  temp file per additional recipient so each component owns its own path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers three merged PRs since 1.7.98:
- #498: V100 staff + V101 projects migrations
- #499: MediaBrowser Embed macro, selection menu/bulk download, admin
  attr, drag-drop file→folder move, toggleable search, drag-drop upload
  at any folder level, unified sidebar/content card, site icon + tab
  title + logo settings
- #500: V102 smart-catalogue + discount migration, multilang debounce
  flow (attach_hook + client-side skeleton toggles), core form
  class/wrapper_class realignment, CSS sources abs-path fix, Elixir
  1.19 test filters, AGENTS.md core form + multilang docs + CHANGELOG
  ownership rule

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ddon ddon merged commit 32a617a into main Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants