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-activitybars (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.cssprogress-bar components - Loads shared
stats.cssandstats.jsfor 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 data —
WebSite,Organization,BreadcrumbList,DiscussionForumPosting,QAPageandProfilePageschemas 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
noindexcontrols — 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 toControllerbase class; analytics page now redirects to a dedicatedadmin/pro-required. pro-required.phpview: gradient banner, blurred stats preview, feature checklist, upgrade CTA linking toFLATBOARD_UPGRADE_LINK; fully translated in 5 languages via newpanel.pro_gate.*admin translation keys- Dark mode fixes: replaced Bootstrap
table-lightthead (rendered white in dark mode) withanalytics-theadclass; replacedtable-primarypeak row withanalytics-row-peak; replacedbg-body-secondarybar track withanalytics-bar-bg; all new classes defined inbackend.cssusingvar(--bs-*)tokens for automatic light/dark adaptation
Admin Analytics Page — Complete Redesign
- Controller now calls
getDetailedStats()to exposeactive_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,usersper 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.totalViewstranslation key (replaced withpanel.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
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) withfa-banon solid red background; replacedfa-lock text-secondary(near-zero contrast) withfa-lockon solid gray background; both usetext-white - KPI cards redesigned with hover lift effect and sub-metric line (
.analytics-kpi-card,.analytics-kpi-subinbackend.css) - Detail card hover effect added (
.analytics-detail-cardinbackend.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.php — getUserDiscussions(), 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>) — alldiscussion.jsonfiles loaded in a singleensureDiscussionsLoaded()pass that also populates$discussionDirCache(directory-path lookup). Every subsequent call togetDiscussion(),getAllDiscussions(),getDiscussionsByCategory(),getDiscussionGlobalNumber(),getDiscussionGlobalNumbersBatch(),getDiscussionByGlobalNumber(),getDiscussionNumber(), andgetDiscussionByNumber()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()andgetLastPostByDiscussion()delegate togetPostsByDiscussion()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 followshtml[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 theemail.verification.*key tree inauth.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 useTranslator::trans() - User management (
UserManagementController) —delete()action now returns a localisedusers.message.deletedmessage; validation-error fallback usesvalidation.error; default ban reason uses the newusers.action.banReasonDefaultkey inadmin.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
fetchPOST to/admin/tags/delete(AJAX) and animates the tag badge out of the DOM on success - The feature is gated by
GroupHelper::isAdmin()orGroupHelper::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 .