feat: v5.0.0 — Live Feed page, Folders & Tags, Link Archiving, API scopes

## Analytics — Live Activity Feed
- Dedicated /stats/live standalone page (mirrors /stats/logs pattern)
- Consistent 3-tab navigation across Statistics / Live Feed / Visit Logs
- checkForUpdates() with skipRender(): O(1) MAX(id) poll — ~100-byte response on no-change
- latestVisitId sentinel (-1) prevents unnecessary first-poll re-render
- Cache key versioned by latestVisitId — fixes bug where concurrent users
  could permanently miss a visit due to stale thundering-herd cache
- 3-second thundering-herd cache (key: url_id + date + filters + latestVisitId)
- Country flags via flagcdn.com (precomputed in PHP, Alpine.js CSP-safe onerror)
- time_ago precomputed in PHP — no Carbon::parse() double-instantiation in Blade
- SELECT only 12 needed columns instead of SELECT *; limit 25 rows
- wire:poll.5s.visible — polling stops when widget is off-screen

## Link Organization
- Folders: one folder per link, clickable → filtered link list, link count badge
- Tags: up to 5 tags per link, clickable → filtered link list, link count badge
- Archiving: soft-archive links instead of permanent deletion; restorable

## REST API
- Per-key API scopes: links:read-only (GET) / links:read-write (full CRUD)
- Per-key rate limiting: individual requests/minute per API key

## Translations
- Added stats_live_feed_poll_interval (EN: 'Updates every 5s', PL: 'Aktualizacja co 5s')

## Docs
- README.md: corrected API scopes/rate-limiting docs (features were already implemented)
- README.md: added Folders, Tags, Archiving to features list
- README.md: added v5.0.0 changelog entry
This commit is contained in:
Bartłomiej Janczak
2026-06-06 02:16:07 +02:00
parent aa05f026b5
commit 2107a3e6c8
48 changed files with 2367 additions and 161 deletions

View File

@@ -64,12 +64,43 @@ class ShortUrlCustomDomain extends Model
*/
protected static function booted(): void
{
$bust = static function (self $m): void {
static::saved(function (self $m): void {
cache()->forget("filament-short-url:custom-domain:{$m->domain}");
};
static::saved($bust);
static::deleted($bust);
$domainChanged = $m->wasChanged('domain');
if ($domainChanged) {
$oldDomain = $m->getOriginal('domain');
if ($oldDomain) {
cache()->forget("filament-short-url:custom-domain:{$oldDomain}");
}
}
// Invalidate the redirect cache keys for all short URLs mapped to this domain
// if the domain name changes or its active status is toggled.
if ($domainChanged || $m->wasChanged('is_active')) {
$hosts = [$m->domain];
if ($domainChanged && isset($oldDomain)) {
$hosts[] = $oldDomain;
}
$shortUrls = $m->shortUrls()->get(['url_key']);
foreach ($shortUrls as $url) {
foreach ($hosts as $host) {
cache()->forget("filament-short-url:{$url->url_key}:{$host}");
}
}
}
});
static::deleted(function (self $m): void {
cache()->forget("filament-short-url:custom-domain:{$m->domain}");
// Clear redirect caches for all URLs mapped to this deleted domain
$shortUrls = $m->shortUrls()->get(['url_key']);
foreach ($shortUrls as $url) {
cache()->forget("filament-short-url:{$url->url_key}:{$m->domain}");
}
});
}
/**