Skip to content

Overhaul publishing module admin UI with status controls, skeleton loading, and trash management - #412

Merged
ddon merged 99 commits into
BeamLabEU:devfrom
mdon:dev
Mar 16, 2026
Merged

Overhaul publishing module admin UI with status controls, skeleton loading, and trash management#412
ddon merged 99 commits into
BeamLabEU:devfrom
mdon:dev

Conversation

@mdon

@mdon mdon commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Major publishing module overhaul: inline status controls on post cards, skeleton loading placeholders on all tab/language switches, full trash management (soft-delete groups/posts, hard-delete empties), clear
translation button, trashed posts blocked from public access, responsive mobile layout, and comprehensive data integrity fixes from two security audits.

Summary

  • Inline status control — Change post status (Draft/Published/Archived) directly from the listing page via a dropdown next to the primary language badge, no need to open the editor
  • Skeleton loading — Animated placeholder cards shown during tab switches (index + listing) and language switches (editor), using deferred message pattern to render skeletons before DB queries
  • Trash management — Full soft-delete workflow for groups and posts with dedicated trash tabs, restore buttons, non-interactive trashed cards, and automatic hard-deletion of empty posts
  • Clear translation — Button in editor sidebar to hard-delete a translation content row (not soft-delete), making the language disappear from the post entirely
  • Public access security — Trashed posts excluded from all public-facing DB queries (get_post, get_post_by_datetime, find_by_url_slug, find_by_previous_url_slug, count_posts), fallback redirects to group
    listing instead of showing other posts
  • WebSocket transport fix — Inline transport cache clearing prevents permanent longpoll fallback after transient WebSocket failures; topbar suppressed on initial connect
  • Responsive mobile — Buttons collapse to icon-only on small screens across index, listing, and editor pages
  • Code quality — Refactored into focused submodules, extracted helpers to fix credo complexity warnings, suppressed dialyzer false positives, removed all filesystem/folder references

mdon and others added 30 commits March 16, 2026 00:43
- Remove deprecated blogging.ex shim, redirect controller, and migration tasks
- Remove all backward-compat aliases (pubsub, renderer, publishing.ex)
- Rename "blogging" type to "blog", remove legacy settings key fallbacks
- Move group storage from JSON settings to DB-only
- Rename tokens (blog-editor/blog-preview → publishing-editor/publishing-preview)
- Update cache key prefix, sitemap terminology, dialyzer config

Add stale value fixers with status reconciliation:
- fix_stale_group (mode, type, item names)
- fix_stale_post (language, status, mode, date/time)
- fix_stale_version/content (status, language)
- reconcile_post_status (cross-level post↔version↔content consistency)

Improve concurrency safety:
- Wrap publish_version and create_post in Repo.transaction
- Use INSERT ON CONFLICT for upsert_content
- Add FOR UPDATE lock to next_version_number
- Check DB return values inside transactions

Harden public controller:
- Expand fallback chain to handle all error reasons gracefully
- Fix timestamp comparison in fallback (Time struct formatting)
- Filter list_times_on_date to published posts only
- Cap fallback language chain at 5 languages
- Guard against nil language in URL builder, nil page_title
- Add pagination safety (division-by-zero, page clamping)
- Nil guards on filter functions (available_languages, language_statuses, metadata)

Harden editor and admin pages:
- Rescue autosave to prevent LiveView crash
- Cancel autosave timer on language switch
- Guard re_read_post against nil post
- Debounce dashboard refresh on index page (500ms)
- Fix migration modal nil crash on empty groups
- Eliminate double DB read in listing refresh
- Cancel timers on group switch and terminate

Add comprehensive error logging:
- All rescue/catch blocks now log exception + context identifiers
- Generic save error handlers log actual error reason
- Preview token verification failures logged with group context
- Cache miss fallback logged

Additional safety:
- remove_group checks post count before cascade delete
- set_translation_status validates status enum
- normalize_version_number rejects non-positive values
- publish_version validates target version exists
- safe_mode_atom replaces String.to_existing_atom
- ListingCache capped at 5000 posts per group

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split the 2560-line publishing.ex monolith into 7 focused modules:

- publishing.ex (451 lines) — thin facade with delegations + module behaviour
- groups.ex (444 lines) — group CRUD (list, add, remove, update, trash)
- posts.ex (692 lines) — post CRUD, reading, updating, path parsing
- versions.ex (364 lines) — version create, publish, delete
- translation_manager.ex (381 lines) — language/translation management
- stale_fixer.ex (350 lines) — stale value detection and status reconciliation
- shared.ex (244 lines) — shared helpers (UUID, audit, read_back, parsing)

All public API signatures unchanged — external callers still use Publishing.*
through defdelegate. No code duplication between submodules (shared helpers
extracted to Shared module).

Add 78 new tests (109 → 187 publishing tests):
- facade_test.exs — verifies all delegations are properly wired
- shared_test.exs — tests parse_timestamp_path, parse_time, uuid_format?,
  fetch_option, should_regenerate_cache?, extract_version_from_parts
- groups_test.exs — tests preset_types, valid_types, fetch_option
- posts_test.exs — tests db_post?, extract_slug_version_and_language
- Updated publishing_api_test.exs — verifies new submodule loading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Route: /admin/settings/publishing/new → /admin/publishing/new-group
- Route placed before :group param route to ensure literal match
- After creation, redirect to new group's listing page instead of settings
- Remove "Create Publishing Group" button from settings page
- Update listing.ex empty-group redirect to /admin/publishing
- Update all internal link references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Settings page now focused on cache management only:
- Remove Primary Language info box
- Remove Publishing Groups list (create/edit/delete/migration buttons)
- Remove remove_group and migrate_primary_language event handlers
- Remove unused assigns (publishing, languages_enabled, global_primary_language)
- Simplify db_groups_to_maps to only include fields needed by cache UI
- Update page subtitle to reflect cache-only focus

Group management is now handled entirely through the publishing interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Route: /admin/settings/publishing/:group/edit → /admin/publishing/edit-group/:group
- Route placed before :group param route to ensure literal match
- After save, redirect to group listing instead of settings
- Cancel redirects to publishing index
- Update edit link in publishing dashboard

Settings page now contains only cache management (listing + render cache).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the complex file-era migration infrastructure with a simple
bulk UPDATE query. With DB-only storage, updating primary_language
on all posts in a group takes milliseconds, not seconds.

Removed:
- MigratePrimaryLanguageWorker (Oban background job)
- Migration modal UI with progress tracking (index.ex, listing.ex)
- Async/sync threshold logic (@migration_async_threshold)
- PubSub started/progress broadcasts
- posts_needing_primary_language_migration cache scan
- migrate_posts_to_current_primary_language (one-by-one updates)
- Migration-related socket assigns (modal state, progress, in_progress)

Replaced with:
- DBStorage.update_primary_language/2 — single UPDATE ... SET query
- DBStorage.count_posts_needing_language_update/2 — single COUNT query
- TranslationManager.update_posts_primary_language/1 — runs query, regenerates cache, broadcasts completed
- Simple "update_primary_language" event handler (no modal, no async)

Kept:
- PubSub broadcast_primary_language_migration_completed (for live UI updates)
- handle_info for completed event (refreshes dashboard/listing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- copy_contents_to_version: for-loop with create_content → single insert_all
- clear_url_slug_from_post: Enum.each with update_content → single update_all
- Update "directory structure" comments to "DB fields" across html.ex
- Update "directory slug/name" to "post slug" in persistence.ex

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reduce autosave debounce from 2000ms to 500ms (DB writes are ~5ms)
- Save immediately on tab switch (visibilitychange event)
- Trigger save on beforeunload before showing warning dialog
- Save before confirming navigation away from editor
- Users can now switch tabs confidently without losing work

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The templates still referenced old migration modal assigns
(show_migration_modal, migration_modal_count, etc.) that were
removed from the LiveView assigns. Replaced with simple
data-confirm buttons that call update_primary_language directly.

Removed from index.html.heex:
- Migration progress bar with spinner
- Migration confirmation modal (75 lines)
- migrations_in_progress map lookups

Removed from listing.html.heex:
- Migration in-progress banner with progress bar
- Replaced show_migration_modal button with direct update_primary_language

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Delete orphaned migration modal from listing.html.heex (73 lines)
  Referenced @show_migration_modal and other removed assigns
- Fix new.html.heex back link: /admin/settings/publishing → /admin/publishing
- Fix new.ex cancel redirect: /admin/settings/publishing → /admin/publishing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The route /admin/publishing/edit-group/:group was placed AFTER
/admin/publishing/:group/:post_uuid, causing Phoenix to match
"edit-group" as a group slug and the actual group as a post_uuid.

Moved edit-group route (and new-group) before all :group param
routes in both localized and non-localized route definitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Was inside rescue block — moved to module level for proper
compile-time macro expansion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
V83 migration adds `status` column to publishing_groups table
(default: 'active'). Groups can now be trashed and restored
instead of permanently deleted.

Schema changes:
- PublishingGroup: add `status` field ('active' | 'trashed')
- PublishingPost: add 'trashed' to valid statuses

DB changes:
- DBStorage.list_groups/1: filters by status (default: 'active')
- DBStorage.list_posts/2: excludes trashed posts by default
- DBStorage.trash_group/1: sets status to 'trashed'
- DBStorage.restore_group/1: sets status to 'active'
- DBStorage.trash_post/1: sets status to 'trashed' (was 'archived')

Business logic:
- Groups.trash_group/1: soft-deletes group (invalidates cache, broadcasts)
- Groups.restore_group/1: restores trashed group (regenerates cache)
- Groups.list_trashed_groups/0: lists only trashed groups
- Posts.trash_post/2: uses 'trashed' status instead of 'archived'

Facade:
- Publishing.restore_group/1: new delegation
- Publishing.list_trashed_groups/0: new delegation

StaleFixer: 'trashed' added to valid_post_statuses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Active/Trashed tabs below stats bar:
- Active tab (default): shows active groups with Open, Settings, View, Trash buttons
- Trashed tab: shows trashed groups with Restore and Delete Forever buttons
- Badge on Trash tab shows count of trashed groups
- Empty state when trash is empty

Event handlers:
- switch_view: toggles between active/trashed views
- trash_group: soft-deletes group with confirmation dialog
- restore_group: restores trashed group
- delete_group: permanently deletes with force (cascades posts)

Dashboard refresh respects view_mode for group filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- text-sm → text-xs, px-4 py-2 → px-3 py-1
- Added -mt-2 to pull tabs closer to stats
- Reduced gap between tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Negative margins pull tabs closer to stats (-mt-3/-mt-6)
and closer to card grid below (-mb-2/-mb-5).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
space-y-4/8 → space-y-2/3 on parent container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each group card already shows its own post/published/draft counts,
making the aggregate stats redundant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add group status check in public controller before serving any
content. When a group is trashed, all its URLs (listing, posts,
versioned posts, timestamp posts) return 404/redirect via the
fallback chain.

The check happens at handle_parsed_path before any cache or DB
lookups, so trashed groups don't trigger cache regeneration.

ListingCache.invalidate is already called when trashing a group,
so cached posts are cleared immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- redirect_if_missing: always go to /admin/publishing instead of
  redirecting to first available group
- Trashed group cards: non-clickable title, dimmed with opacity-60,
  no hover effect, only Restore and Delete Forever buttons
- Active group cards: unchanged behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When another user trashes a group, viewers on that group's listing
page now redirect to /admin/publishing instead of to another group.

PubSub coverage for group trash/restore:
- Index page: group_deleted → refresh dashboard (shows updated active/trash)
- Index page: group_created (restore) → refresh dashboard
- Listing page: group_deleted → redirect to /admin/publishing if viewing that group
- Settings page: group_deleted → refresh cache group list
- Editor: no group-level subscription (autosave fails gracefully)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed Drafts count — only total posts and published matter
at the dashboard level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shows "Languages used: 3" as a clickable label with chevron.
Clicking reveals the language badges. Uses HTML details/summary
for zero-JS accordion behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove "What happens next" info card and subtitle from header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Group cards (index): hide "View" button when published_count is 0
- Group listing header: hide public URL when no published posts
- Per-post listing: hide public URL for draft/archived/trashed posts
- Editor: already only shows URL when status is "published" (unchanged)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove "Showing X posts" text — unnecessary for the user
- Show first 20 posts by default
- "Load more" button appears when there are more than 20 posts
- Each click loads 20 more posts
- visible_count resets on group switch via handle_params

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mdon and others added 20 commits March 16, 2026 00:43
Load trashed posts with full metadata (titles, languages, versions)
instead of bare struct-to-map conversion that showed everything as
untitled. Make trashed post cards non-interactive: title is plain text,
primary language is a static badge with status dot, translations shown
dimmed with pointer-events-none, no public URL. Move restore button
next to primary language with clear green styling. Add cursor-pointer
to tab buttons on listing and index pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Index page: group action buttons show icon-only on mobile with flex-1
to share space evenly, full labels on desktop.

Editor page: preview and view public buttons show icon-only on mobile,
header row uses flex-wrap for clean wrapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add p.status != "trashed" filter to find_by_url_slug and
find_by_previous_url_slug queries. These public-facing functions
were leaking trashed posts that could still be accessed via
custom URL slugs or previous URL slug redirects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- count_posts: exclude trashed posts so groups with only trashed posts
  can be deleted without force flag
- publish_version: reject publishing versions on trashed posts
- restore_group: check for active slug collision before restoring a
  trashed group to prevent duplicate slugs

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

Add documentation for inline status controls, skeleton loading,
trash management, and clear translation. Fix outdated references
to soft_delete_post, deleted_at timestamps, and folder-based storage.
Update DBStorage API examples to match current function signatures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract load_initial_data, apply_full_reload, default_tab_mode, and
filter_posts_for_mode from listing mount/handle_params to reduce
cyclomatic complexity. Extract primary_content_missing?,
fix_version_primary_content, find_best_translation,
copy_translation_to_primary, and maybe_promote_post_to_published
from stale_fixer fix_missing_primary_content to reduce nesting depth.

Suppress dialyzer false positives for defensive catch-all pattern
matches and dynamic settings_call dispatch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The blog route constraint only matched 2-letter language codes (en, fr)
but not dialect codes like en-US or zh-CN. URLs with dialect codes fell
through to the non-localized catch-all route which treated the language
as a group slug, causing broken redirects.

Update language regex from ^[a-z]{2}$ to ^[a-z]{2,3}(-[A-Za-z]{2,4})?$
in blog routes, sitemap generator, layout wrapper locale stripping,
dashboard tab matching, and tab item locale detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The preview handler only passed the language parameter, causing
read_post_by_uuid to load the latest version (which could be a draft).
When navigating back from preview, the editor loaded the draft version
where status was "draft", hiding the View Public button. Now the
current version is preserved through the preview round-trip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When only one dialect is enabled (e.g., en-US), the canonical URL uses
the base code (en). The listing page used filter_by_exact_language_strict
which only matched exact codes — "en" didn't match posts stored as
"en-US", triggering a fallback redirect back to en-US, creating an
infinite redirect loop.

Switch to filter_by_exact_language which does base-code matching, so
"en" correctly finds posts with "en-US" content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update all language/locale regex patterns across the codebase from
2-letter-only to 2-3 letter base codes with optional 2-4 letter
dialect suffix. Fixes admin routes rejecting 3-letter language codes,
user locale validation rejecting valid codes, and inconsistent
matching between blog routes and admin routes.

Files updated:
- integration.ex: admin scope locale constraint and dynamic pattern
- language.ex: looks_like_language_code?, base_code?, valid_language?
- user_dashboard_nav.ex: looks_like_locale?
- admin_nav.ex: locale_candidate?
- user.ex: validate_locale_value and doc string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A slug-mode post with a date-formatted slug (e.g., "2026-03-13") was
misrouted as a date-only timestamp URL. When list_times_on_date
returned empty, it returned :post_not_found instead of trying the
slug. Now attempts a slug lookup before giving up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a group's URL mode is changed (e.g., timestamp → slug), existing
posts retain their old mode. The stale fixer now detects this mismatch
and updates the post mode to match the group, preventing misrouted
URLs where timestamp-mode posts in slug-mode groups generate date-based
URLs instead of slug-based ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stale fixer: when a post's mode changes from timestamp to slug, auto-
generate a slug from the post title, date, or UUID if the post has no
slug. Prevents broken URLs where slug-mode posts have nil slugs.

Restore handler: call reconcile_post_status immediately after restoring
a trashed post to draft, ensuring version/content statuses are demoted
from published to match the draft post status right away instead of
waiting for the next stale fixer pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three locations used pure atom pattern matching on post.mode (:timestamp)
which fails when mode is a string ("timestamp"). The mapper converts
DB strings to atoms, but cached posts or direct DB reads may have
strings. Changed all three to use defensive dual checks:
mode in [:timestamp, "timestamp"]

Files fixed:
- post_rendering.ex: find_cached_post
- translations.ex: find_cached_post
- editor/helpers.ex: build_url_for_mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Stale fixer background task: wrap in try/rescue so crashes are logged
  instead of silently swallowed by Task.start
- Autosave failure: show flash error to user instead of silently
  resetting the saving indicator — user now sees "Autosave failed"
- Slug generation failure: log warning when slug can't be generated
  for a post that switched to slug mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move inline aliases (DBStorage, ListingCache, Shared) from
  clear_translation handler to module-level aliases in editor.ex
- Extract shared do_update_post_status helper to deduplicate
  change_status and toggle_status handlers in listing.ex
- Make empty_post? check safer: only auto-delete draft/trashed posts,
  never published or archived ones even if they appear empty

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Empty posts (no content across any version) should be cleaned up
regardless of status. A published post with zero content is legacy
corruption, not intentional — keeping it just confuses users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs caused blank content on controller-rendered pages:

1. get_layout_config/0 hardcoded :app function for :modern strategy,
   ignoring the explicit layout: config. Parent apps configuring
   layout: {MyAppWeb.Layouts, :full_width} got :app instead. Now
   checks layout: config first, falls back to {layouts_module, :app}.

2. Controller templates were double-wrapped: LayoutWrapper in the
   template AND Phoenix's automatic app layout both applied the parent
   layout. The second wrap passed inner_block: [] (truthy in Elixir),
   causing render_slot([]) to return empty output. Fixed by adding
   plug :put_layout, html: false to the publishing controller so
   only LayoutWrapper handles layout wrapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The blank page was caused by the parent app's layout checking
inner_block ([] is truthy) before inner_content. This is a parent
app layout pattern issue, not a PhoenixKit bug. Disabling Phoenix's
controller layout system breaks parent apps that rely on it.

Parent apps experiencing double-wrapping should add put_layout(conn,
html: false) in their own pipeline or fix their layout to handle
inner_block: [] correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mdon and others added 6 commits March 16, 2026 01:09
Resolve base language codes (de, en, fr) to stored BCP-47 dialect
codes (de-DE, en-US, fr-FR) before URL slug DB queries, since content
rows store full dialect codes but URL paths use base codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve base language codes (de, en) to stored BCP-47 dialect codes
before passing to DBStorage.read_post, so resolve_content's exact
match finds the correct language content instead of falling back to
primary language and triggering a redirect.

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>
…ants

Centralizes duplicated inline lists (post statuses, content statuses,
group statuses, modes, types) into a single Constants module. Replaces
20+ scattered hardcoded arrays across schemas, business logic, controllers,
and templates with references to the shared constants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add default_title ("Untitled") and schema max lengths to Constants
- Replace 20+ hardcoded "Untitled" strings with Constants.default_title()
- Fix missed inline mode check in controller/listing.ex
- Deduplicate get_language_name (remove private copies from index.ex
  and listing.ex, reuse public version from Editor.Helpers)
- Consolidate find_dialect_for_base_in_languages as delegate to
  find_dialect_for_base (identical implementations)
- Extract normalize_string/normalize_slug helpers in editor forms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ddon
ddon merged commit 7f8af65 into BeamLabEU:dev Mar 16, 2026
6 checks passed
ddon added a commit that referenced this pull request Mar 16, 2026
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

2 participants