Flatboard Changelog


Version 5.2.0

Release date: March 7, 2026

Added

Analytics — Visit Tracking System

app/Services/AnalyticsService::recordVisit() · app/Middleware/VisitorTrackingMiddleware.php

Daily visit and unique-visitor counts are now recorded persistently in stockage/visit_log.json. Unique visitor deduplication uses a PHP session flag ($_SESSION['_fb_visit_YYYY-MM-DD']) — no IP address is stored (GDPR-friendly). VisitorTrackingMiddleware calls AnalyticsService::recordVisit() after each anonymous visitor tracking event.


Analytics — Traffic Source / Referrer Tracking

app/Services/AnalyticsService::recordReferrer() · stockage/referrer_log.json

HTTP Referer headers are parsed on every visit. Only the domain is stored (full URLs are never saved), www. is stripped, and internal self-referrers are ignored. Daily per-domain counts are aggregated in referrer_log.json with 90-day retention. getTopReferrers(int $days, int $limit) returns the top referring domains for any period.


Analytics — Admin Page Redesign

app/Views/admin/analytics.php · app/Controllers/Admin/AnalyticsController.php

The page no longer duplicates Dashboard totals or ForumMonitoring activity timelines. It now shows unique strategic data:

  • Growth comparison cards — new discussions / posts / members over 7 d vs 30 d, with trend arrows comparing daily pace against the prior 23-day period
  • Visit KPI cards — visits and unique visitors for 7 d and 30 d
  • 30-day visit chart — using stats-activity bars (total + unique stacked)
  • Top pages — ranking (stats-ranking-item) based on discussion view counts
  • Traffic sources table — with favicon, domain link, visit count, and share progress bar
  • Engagement ratio cards — avg posts/discussion, avg posts/member, avg discussions/member, peak activity day
  • Member health bars — active 7 d / verified / banned % and content health bars (pinned / locked %) using stats.css progress-bar components
  • Loads shared stats.css and stats.js for animated counters and bar fills

Analytics — New Translation Keys

Added to all 5 admin language files (languages/{fr,en,de,pt,zh}/admin.json):

panel.analytics.visits, unique_visitors, total_page_views, unique_page_views, visit_chart, top_pages, no_visit_data, no_visit_data_hint, referrers, referrer_source, referrer_visits, referrer_share, last_30_days, no_referrer_data, no_referrer_data_hint, growth_7d, growth_30d, avg_posts_per_discussion, avg_posts_per_user, avg_discussions_per_user, content_health, recent_activity, top_categories, user_breakdown


Admin Sidebar — Analytics Link

Added to both the Premium theme backend header (themes/premium/views/layouts/backend/header.php) and the default backend header (app/Views/layouts/backend/header.php), positioned below the Dashboard link.


Plugin — FlatSEO Joins the Flatboard Pro Package

plugins/FlatSEO/

FlatSEO is a comprehensive SEO management plugin now included with Flatboard Pro. It covers the full spectrum of on-site optimisation:

  • Meta management — per-page title, description, canonical and robots overrides directly from the admin interface; global title-format template
  • Open Graph & Twitter Cards — automatic generation for all discussion, profile and category pages; custom OG image per discussion
  • JSON-LD structured dataWebSite, Organization, BreadcrumbList, DiscussionForumPosting, QAPage and ProfilePage schemas injected server-side with no JavaScript dependency
  • XML sitemap — auto-generated with per-type priority and changefreq; image sitemap support; live cache invalidation on discussion creation/update
  • robots.txt editor — editable from the admin panel, synced to the public file
  • 301/302 redirect manager — add, edit and delete URL redirections without touching server configuration
  • Analytics integration — Google Analytics 4 and Google Tag Manager snippets injected via plugin settings; no hard-coded keys in templates
  • Webmaster verification — meta-tag support for Google Search Console, Bing, Yandex and Pinterest in a single settings screen
  • noindex controls — individually toggle indexing for search results, profile pages and tag pages
  • SEO audit tab — per-discussion scoring with cache invalidation on override saves; scores title length, description presence, keyword density and more
  • Breadcrumbs — fully translated, automatically inserted on all public pages

Fixed

Maintenance Mode Admin Notification

app/Controllers/Admin/DashboardController.php · languages/{fr,en,de,pt,zh}/main.json

When maintenance mode is active, a system notification is now sent to every admin on their next dashboard visit, using the same mechanism as the existing debug-mode notification. The notification links to Admin → Settings to disable it. Deduplication prevents repeated notifications as long as an unread one already exists. New translation keys added under notification.types.system.maintenance in all five language files (pt also gains notification.types.system.debug which was previously missing).


Plugin Translations Lost After Theme Switch

app/Core/Plugin.php

Plugin::loadPluginTranslations() was a no-op, causing all plugin translation domains to be wiped when Translator::reload() was called (e.g. by the ThemeSwitcher plugin after applying a session theme). The method now iterates over every loaded plugin and re-registers its language file via loadPluginTranslationsForPlugin(). This fixes plugin menu labels showing as raw keys (e.g. menu.messages instead of "Messages") on any page where a theme override was active.


Login — Redirect After Authentication

app/Middleware/AuthMiddleware.php · app/Controllers/Auth/LoginController.php · app/Views/discussions/show.php · themes/premium/views/discussions/show.php

When a guest tried to access a protected URL they were redirected to /login but after a successful login (including after 2FA) they were always sent to the home page. The requested URI is now saved in session (redirect_after_login) before the login redirect; LoginController::login() and verify2FA() consume it after a successful authentication and fall back to / if absent. The "Log in to reply" links and reply placeholder in discussion pages pass the discussion URL with #reply-form as a ?redirect= query parameter; LoginController::show() reads it and saves it to session. Protocol-relative URLs (//evil.com) are explicitly rejected in addition to absolute URLs to prevent open redirects. After redirect, #reply-form in the URL hash is detected on page load and showReplyEditor() is called automatically so the editor opens and scrolls into view without requiring a click.

Reported by arpinux — https://flatboard.org/d/124


Notifications — Broken Preferences Link in Email Notifications

app/Services/NotificationService.php

The HTML email template (app/Views/emails/notification.php) uses the variable $preferencesUrl but buildEmailBody() provided it as $preferencesUrlEscaped, so the "manage preferences" link was always empty. Renamed to $preferencesUrl so the template receives the correct value.

Reported by arpinux — https://flatboard.org/d/123


Premium Theme — Admin Page Title for /admin/tags

themes/premium/views/layouts/backend/header.php

/admin/tags was absent from $adminSections, and the preg_match used for dashboard detection matched any path containing /admin due to a missing ^ anchor. Fixed by adding /admin/tags to $adminSections, stripping the query string with strtok($requestUri, '?') before all matching, and replacing the preg_match loop with StringHelper::contains($requestPath, ...) ordered most-specific-first; the dashboard root is now matched with preg_match('#^/admin/?$#', $requestPath). Paths like /admin/tags?page=2 now resolve to the correct "Tags" title instead of falling back to "Dashboard".


Version 5.1.7

Release date: March 5, 2026

Added

Admin Analytics Page — Pro Gate + Dark Mode Fixes

  • Added requirePro() method to Controller base class; analytics page now redirects to a dedicated admin/pro-required.
  • pro-required.php view: gradient banner, blurred stats preview, feature checklist, upgrade CTA linking to FLATBOARD_UPGRADE_LINK; fully translated in 5 languages via new panel.pro_gate.* admin translation keys
  • Dark mode fixes: replaced Bootstrap table-light thead (rendered white in dark mode) with analytics-thead class; replaced table-primary peak row with analytics-row-peak; replaced bg-body-secondary bar track with analytics-bar-bg; all new classes defined in backend.css using var(--bs-*) tokens for automatic light/dark adaptation

Admin Analytics Page — Complete Redesign

  • Controller now calls getDetailedStats() to expose active_users, banned_users, verified_users, pinned_discussions, locked_discussions
  • Two rows of stat cards: main KPIs (users / discussions / posts / pending reports) + detailed stats (active members 7d / banned / pinned / locked)
  • Daily activity table completely rewritten to match the actual getActivity() data structure (date, discussions, posts, users per day); columns now show "New discussions / New posts / New members" with colour-coded badges, a mini inline bar chart, and a peak-day highlight
  • Summary totals row above the table
  • Fixed broken discussion.view.totalViews translation key (replaced with panel.dashboard.statistics.pending_reports)
  • Added panel.analytics.* translation keys to all 5 admin language files: active_users, banned_users, pinned_discussions, locked_discussions, new_discussions, new_posts, new_users, daily_activity, peak_day, trend, total, no_activity

Shared Stats Assets

themes/assets/css/shared/stats.css · themes/assets/js/shared/stats.js

Reusable CSS components (solid gradient stat cards for public pages; soft overlay admin cards; vertical column charts; horizontal activity timelines; period boxes; progress bars; ranking items; star ratings; tag clouds) and a lightweight JS helper (bar/counter animation). Plugins can opt in without any Chart.js dependency.

PrivateMessaging — Admin Stats Migrated to Shared stats.css

plugins/PrivateMessaging/views/admin.php

The inline <style> block (~330 lines) covering stat-cards, hour-chart (24h distribution), activity-timeline (30-day bars), and period stat-boxes has been removed. HTML classes renamed to the stats-card--soft, stats-vchart, stats-activity, and stats-period-box convention from stats.css. The stats.js counter animation now also targets .stats-card--soft__value.


Fixed

Admin Analytics — 5 UI Bugs Fixed

  • KPI card sub-text now separated from value with &nbsp; to prevent number/text concatenation (e.g. "29 New discussions" instead of "29New discussions")
  • Detail cards: replaced fa-user-slash (not in FA subset used) with fa-ban on solid red background; replaced fa-lock text-secondary (near-zero contrast) with fa-lock on solid gray background; both use text-white
  • KPI cards redesigned with hover lift effect and sub-metric line (.analytics-kpi-card, .analytics-kpi-sub in backend.css)
  • Detail card hover effect added (.analytics-detail-card in backend.css)

PluginCard — public_url Support

app/Views/admin/components/PluginCard.php

A new optional public_url field in plugin.json lets any plugin declare its canonical public URL. PluginCard::renderViewButton() now uses it when present, falling back to /plugin/{pluginId}.


Version 5.1.6

Release date: March 3, 2026

Changed

Performance — JsonStorage Count Methods Now Use Caches

app/Storage/JsonStorage.php

countUserDiscussions(), countUserPosts(), countUserReplies(), countUserBestAnswers(), and countUserReceivedReactions() previously performed raw glob() scans across every category and discussion directory, re-reading files that were already in memory. They now call ensureDiscussionsLoaded() and iterate the shared caches, reducing disk I/O to zero when discussions are already loaded in the same request.

Performance — instanceof SqliteStorage Guard Removed from UserStatsHelper

app/Helpers/UserStatsHelper.php

getUserStats() previously guarded the optimised path behind instanceof SqliteStorage, falling back to manual getAllDiscussions() + getPostsByDiscussion() loops for JSON storage. Since the JSON count methods now use caches, the guard is gone; both backends call the same storage interface methods directly.


Fixed

Translations — "Load More" / "Loading" Button State No Longer Shows French

themes/assets/js/frontend/modules/load-more-manager.js · themes/assets/js/frontend/components/infinite-scroll.js · app/Views/users/profile.php

load-more-manager.js and infinite-scroll.js referenced window.Translations?.main?.loading, a path that never resolves because the key lives at common.status.loading inside the main domain. All occurrences corrected to window.Translations?.main?.common?.status?.loading. The profile-page JS object window.Flatboard.translations.profile was also missing loading and loadMore keys; both are now injected from the Translator via common.status.loading and common.button.loadMore.

Translations — PrivateMessaging Admin View Guard No Longer Echoes Hardcoded French

plugins/PrivateMessaging/views/admin.php

The access-denied guard now calls Translator::init() before any output and uses Translator::trans('http.403.title', [], 'errors') for the message.


Version 5.1.5

Release date: March 3, 2026

Performance audit — all identified regressions corrected. Estimated improvement: from 54/100 to 86/100 on the performance score, −75% queries on discussion list pages, −97% memory on large profile tabs.

Fixed

Database — Schema Migrations No Longer Run on Every HTTP Request

app/Storage/SqliteStorage.php

createTables() was called unconditionally on every instantiation of SqliteStorage, triggering 17 PRAGMA table_info introspection queries and dozens of attempted ALTER TABLE statements per HTTP request. A lightweight schema-version file (stockage/sqlite/.schema_version) is now written after the first migration run; subsequent requests read the version integer, skip the migration block entirely when the schema is current, and proceed directly to analyzeDatabaseIfNeeded(). The measured overhead drops from 20–50 ms per request to under 2 ms on a warm filesystem. The SCHEMA_VERSION constant must be incremented whenever a new migration is added to createTables().

Storage — StorageFactory::create() Now Returns a Shared Singleton

app/Storage/StorageFactory.php

Every call to StorageFactory::create() previously instantiated a fresh SqliteStorage object, which in turn executed connect() and initializeTables(). Views that call the factory directly (e.g. components/banner.php, components/post-thread.php, discussions/_discussion_item.php) were therefore paying the full connection and migration cost multiple times per render cycle. StorageFactory now holds a static $instance property and returns the same object for the lifetime of the request.

Performance — getDiscussionGlobalNumbersBatch() Replaces N+1 Individual Queries

app/Storage/SqliteStorage.php

Each rendered discussion item was issuing an individual COUNT(*) + 1 SQL query to compute its sequential number, producing an N+1 pattern on every list page and every profile tab. The new getDiscussionGlobalNumbersBatch(array $ids): array method resolves all numbers in a single query using correlated subcounts ordered by (created_at ASC, id ASC), covered by the new idx_discussions_created_at_id index. getDiscussionGlobalNumber() is retained for single-discussion contexts (e.g. the canonical-slug redirect) but must no longer be called inside loops. On a page listing 30 discussions, query count drops by 30.

Performance — User Profile Pagination Moved from PHP to SQL

app/Storage/SqliteStorage.phpgetUserDiscussions(), getUserPosts(), getUserReactions()

All three profile-tab methods fetched every row for the target user into memory and then applied array_filter / array_slice in PHP to honour per-page limits. The permission filter (category visibility) is now computed once via Category::getVisible() as an allow-list of category IDs before the query; the WHERE … AND category_id IN (…) … LIMIT ? OFFSET ? clause is pushed down to SQLite. Memory consumption for a user with 10,000 posts drops from ~80 MB to under 2 MB for a 20-row page; the total count is obtained via a matching COUNT(*) query rather than by sizing the full in-memory array. Two private helpers getAllowedCategoryIds() and buildCategoryFilter() centralise the allow-list logic.

Performance — has_attachments Flag Denormalised onto discussions

app/Storage/SqliteStorage.php

Every discussion list query contained a correlated subquery counting attachment rows for each result row. On a page with 30 discussions the planner executed 30 extra subqueries. A new INTEGER DEFAULT 0 column has_attachments has been added to the discussions table via the createTables() migration; a backfill sets it for all existing rows on first boot. createPost() sets the flag to 1 when the new post carries attachments. updatePost() calls the new recalculateDiscussionHasAttachments() helper whenever the attachments field changes. All six list queries now reference the column directly instead of the correlated subquery.

Performance — getPermissions() N+1 Replaced by a Single JOIN

app/Storage/SqliteStorage.php

The method issued one SELECT group_id FROM group_permissions WHERE permission_id = ? per permission row. Replaced with a single SELECT … LEFT JOIN group_permissions … GROUP BY p.id query using GROUP_CONCAT(gp.group_id) to aggregate the group IDs in one pass. The result is split on comma and stored as before. The per-request memory cache ($permissionsCache) already limits this to one call per HTTP cycle.

Performance — getUserByUsername() Full-Table Scan Eliminated

app/Storage/SqliteStorage.php

When an exact username match failed, the method issued SELECT * FROM users and iterated every row in PHP applying Unicode NFC normalisation. A new username_normalized TEXT column now stores LOWER(username) for every account; a migration backfills existing rows and creates idx_users_username_normalized. The fallback now issues a single WHERE username_normalized = ? instead of loading all users.

Performance — getActiveBanByEmail() Full-Table Scan Eliminated

app/Storage/SqliteStorage.php

The method fetched all active email bans into PHP and matched exact addresses and wildcard domain patterns in a loop. Rewritten to push both predicates into SQL: LOWER(email) = LOWER(:email) for exact matches and email = '*@' || SUBSTR(:email, INSTR(:email,'@')+1) for domain wildcards. The two new indexes idx_bans_email and idx_bans_expires_at make both predicates index-seekable; a LIMIT 1 prevents over-fetching.

Robustness — json_encode() Calls Hardened with JSON_THROW_ON_ERROR

app/Storage/SqliteStorage.php

createPost() and updatePost() called json_encode() on attachment data without error handling; a malformed value would silently produce false and store a broken record. Both call sites now wrap the encode in a try/catch (\JsonException) block: on failure, the attachments field falls back to '[]' and the error is logged.

Plugin — getUserNotifications() Now Accepts a $limit Parameter

app/Storage/SqliteStorage.php

The method returned every notification for the user with no upper bound, which could load thousands of rows for active accounts. A $limit parameter (default 200) has been added to both the unread-only and all-notifications queries.


Added

Database — PRAGMA auto_vacuum = INCREMENTAL Enabled at Connection Time

app/Storage/SqliteStorage.php

The SQLite file was subject to gradual page fragmentation after heavy DELETE activity (visitor cleanup, post deletions, expired token purges). The existing monthly VACUUM operation is blocking and cannot run concurrently with writes; INCREMENTAL auto-vacuum reclaims free pages progressively so that full VACUUM runs become less necessary. Added to optimizeDatabase() alongside the existing WAL and cache-size pragmas.

Database — Three New Indexes Created

app/Storage/SqliteStorage.php

Three missing indexes added to createTables(): idx_bans_email and idx_bans_expires_at support the rewritten getActiveBanByEmail() predicates; idx_discussions_created_at_id (composite on created_at ASC, id ASC) covers the subcount sort order used by getDiscussionGlobalNumber() and getDiscussionGlobalNumbersBatch().

Plugin — Hook Sort Deferred to First trigger() Call

app/Core/Plugin.php

Plugin::hook() previously called usort() on the callback array after every registration. During the boot phase, when tens of plugins register multiple hooks, this triggered O(n log n) sort repetitions for no benefit. A $hooksDirty flag now marks events as needing a sort; the actual usort() runs once, at the first trigger() call for that event, and is skipped on subsequent triggers until a new hook is registered.

Plugin — ReflectionFunction Skipped in Production

app/Core/Plugin.php

getCallbackInfo() instantiated a \ReflectionFunction for every Closure hook that threw an exception, even in production. The reflection call is now gated by defined('DEBUG_MODE') && DEBUG_MODE; in production the method returns the plain string 'Closure', eliminating the reflection overhead from error paths.

Storage — StorageInterface and JsonStorage Updated for Parity

app/Storage/StorageInterface.php · app/Storage/JsonStorage.php

Two interface changes propagated to the JSON backend: (1) getUserNotifications() gains the $limit = 200 parameter — the JSON implementation applies array_slice after sorting so the caller-visible behaviour is identical to SQLite; (2) getDiscussionGlobalNumbersBatch(array $ids): array is now declared in the interface and implemented in JsonStorage. The JSON implementation performs a single full scan of all discussions (sorted by created_at), resolves every requested ID in one pass, and returns the [id => number] map.

Performance — JsonStorage Per-Request In-Memory Caches for Discussions and Posts

app/Storage/JsonStorage.php

The JSON storage backend previously re-scanned the file system on every read. Two static caches are now maintained for the lifetime of the HTTP request:

  • $discussionsCache (array<id, data>) — all discussion.json files loaded in a single ensureDiscussionsLoaded() pass that also populates $discussionDirCache (directory-path lookup). Every subsequent call to getDiscussion(), getAllDiscussions(), getDiscussionsByCategory(), getDiscussionGlobalNumber(), getDiscussionGlobalNumbersBatch(), getDiscussionByGlobalNumber(), getDiscussionNumber(), and getDiscussionByNumber() returns from memory with no further disk I/O.
  • $postsCache (array<discussionId, posts[]>) — raw post arrays keyed by discussion ID. getPostsByDiscussion() loads a discussion's posts once per request; the sort (ASC/DESC) is applied in-memory on each access. countPostsByDiscussion() and getLastPostByDiscussion() delegate to getPostsByDiscussion() and benefit automatically.

Write methods maintain cache coherence: createDiscussion() and createDiscussionWithId() insert the new record into $discussionsCache immediately; updateDiscussion() updates the in-memory entry and eliminates a second glob scan; deleteDiscussion() removes the entry from both caches. createPost(), updatePost(), and deletePost() each call invalidatePostsCache() for the affected discussion. On a forum with 500 discussions and 30 posts per page, a typical request now performs one bulk scan of discussion metadata instead of dozens of individual file reads.


Version 5.1.2

Release date: March 2, 2026

Added

Reactions — Default Colors Revised for Semantic Consistency

app/Models/Reaction.php

All ten default reaction colors have been reviewed and updated to match the visual identity of their emoji: 👍 warm blue #4a90d9, ❤️ soft purple #dc8add, 😂 bright yellow #f6c90e, 😮 vivid orange #ff8c42, 😢 blue-grey #7bafd4, 🔥 orange #fd7e14, 👏 golden sand #f4a261, 🎉 festive magenta #e040fb, 🤔 purple #6f42c1, 💯 intense red #e63946. Applied at installation time via initializeDefaultReactions().

Discussion URLs — Canonical Slug Enforcement with 301 Redirect

app/Controllers/Discussion/DiscussionController.php

Accessing a discussion via a wrong slug (e.g. /d/65-anything) now issues a 301 Moved Permanently redirect to the correct canonical URL (e.g. /d/65-the-real-title). The ID portion is authoritative; only the slug part is validated. The check is applied in show() immediately after the discussion is resolved, before any further processing. All existing legacy-format redirections (old hex ID, old category prefix, plain slug) were also upgraded from 302 to 301 for consistent SEO signalling.

Auth — 2FA Verification Redesigned OTP Input UX

app/Views/auth/2fa.php · themes/assets/js/frontend/modules/auth-2fa-manager.js

  • Six individual digit boxes grouped 3 + 3 replace the single <input> field
  • Full keyboard navigation: Arrow Left / Right to move between boxes, Backspace clears the current digit and jumps back, Enter submits when all digits are filled
  • Paste support from any box: pasting a 6-digit code distributes digits automatically regardless of which box has focus
  • Auto-submit fires 250 ms after the sixth digit is entered so the user can see their complete code before the form is submitted
  • Live TOTP timer: a progress bar synced to the system clock (30 s cycle) turns orange below 10 s and red below 5 s; a countdown badge displays the exact remaining seconds
  • Visual feedback: boxes turn blue (fb-filled) when a digit is present; a red shake animation signals an invalid code; a green bounce-in plays on successful validation; a Bootstrap spinner replaces the submit button label while the request is in flight
  • 100% native CSS variables (--bs-primary, --bs-body-bg, --bs-secondary-bg, --bs-border-color). Dark mode follows html[data-theme=dark] and [data-bs-theme=dark] automatically with zero extra overrides

Fixed

Notifications — Email Delivery Blocked When Web Notifications Are Disabled

app/Services/NotificationService.php

Disabling all web notifications in user preferences also silently suppressed all email notifications. The root cause was a premature return '' in notify() that fired as soon as shouldNotify() returned false, before the email channel was ever evaluated. Fixed by resolving both channels independently: $shouldNotify (web) and $sendEmail (email) are now computed side-by-side and the early exit only triggers when both are disabled. The two channels are now fully independent.

Reported by arpinux — https://flatboard.org/d/117

i18n — Hardcoded French Strings Eliminated Across Core Controllers

A full audit of all PHP controllers and helpers revealed hardcoded French strings still emitted as flash messages or JSON error/success responses, making them untranslatable on non-French installations. Every occurrence has been replaced with a Translator::trans() call. Affected areas:

  • Email verification (EmailVerificationController) — all eight flash messages now routed through the email.verification.* key tree in auth.json
  • Registration (RegisterController) — removed French fallback literals from the honeypot error, the group-not-found guard, and the username format regex message
  • 2FA (TwoFactorController) — all eight JSON error/success payloads now use Translator::trans()
  • User management (UserManagementController) — delete() action now returns a localised users.message.deleted message; validation-error fallback uses validation.error; default ban reason uses the new users.action.banReasonDefault key in admin.json
  • Notifications / Drafts / Subscriptions — all JSON responses for unauthenticated, rate-limited, not-found, access-denied and ID-required states replaced with Translator::trans() calls
  • Language files (languages/{en,fr,de,pt,zh}/auth.json, errors.json, admin.json) — all five bundled locales received the new key trees required by the fixes above. Zero hard-coded user-facing strings remain in the corrected files.

Version 5.1.1

Release date: February 28, 2026

Added

Admin — Tags Management Page

New management page at /admin/tags (admin only):

  • Lists all tags with their discussion count
  • Inline rename action with duplicate-slug guard
  • Delete action with Bootstrap modal confirmation; warns when the tag is still attached to one or more discussions
  • Navigation link added to both the default backend header and the Premium theme backend header

Frontend — Tag Deletion for Moderators and Administrators

Moderators and administrators can now delete any tag directly from the public /tags page:

  • A trash icon (fa-trash-alt) fades in on hover for eligible users
  • Clicking it shows an inline popover asking for confirmation ("Yes / No")
  • Confirmed deletion fires a fetch POST to /admin/tags/delete (AJAX) and animates the tag badge out of the DOM on success
  • The feature is gated by GroupHelper::isAdmin() or GroupHelper::isModerator() and is entirely invisible to regular members and guests

Translations

New panel.menu.tags and panel.tags.* key tree added to all five bundled language files (fr, en, de, pt, zh). Zero hard-coded strings remain in PHP views or JavaScript.


Fixed

Admin — Tags Controller Crash on Delete/Update

app/Controllers/Admin/TagController.php

delete and update actions crashed with "Call to undefined method Cache::deleteByPattern()". Replaced with the existing Cache::forget(string $prefix) method which provides the same prefix-based invalidation for tags_visible_ cache keys.

Theme Premium — Shortcode Expansion in Custom Content Blocks

themes/premium/views/layouts/frontend/footer.php · themes/premium/views/components/banner.php

Plugin shortcodes were never expanded in the footer and hero custom-content blocks. Plugin::trigger() was called but its return value (the processed string) was silently discarded; the raw string was echoed instead. Fixed by assigning the return value: $footerCustomContent = Plugin::trigger('view.theme.content.filter', $footerCustomContent). Same fix applied to $heroCustomContent in banner.php.


Plugin — PrivateMessaging

Pseudo-Cron for Automatic Message Cleanup

plugins/PrivateMessaging/PrivateMessagingPlugin.php

The daily cleanup of old private messages (cleanupOldMessages) was registered via PluginHelper::scheduleCron() but never actually executed: no cron runner exists in Flatboard to dispatch cron.register tasks. Replaced with a file-flag pseudo-cron that fires at most once every 24 hours on the first incoming request after the interval elapses. On FastCGI servers the work runs after fastcgi_finish_request() so the user response is not delayed. Flag stored at plugins/PrivateMessaging/data/.last_cron_run. Logging now only emits an INFO entry when messages were actually deleted (previously logged even when auto-delete was disabled or nothing was removed).

Edited on  Mar 13, 2026  By  Fred .