Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2107a3e6c8 |
48
README.md
48
README.md
@@ -51,7 +51,10 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
|
||||
- 🌐 **Custom Domain Branding (new in v4.0.0)** — Register custom domains, show dynamic DNS setup instructions (A/CNAME records), verify records in real-time, and route redirects directly at the root of verified custom domains.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — Route and analyze traffic with offline MaxMind detection, CDN edge headers (Cloudflare's `CF-IPCountry`, CloudFront), or fallback APIs.
|
||||
- 🗺️ **Interactive Visitor World Map** — Showcase geographic click distribution on a beautiful SVG world map widget with real-time hover details.
|
||||
- 📈 **Comprehensive Analytics Dashboard** — Monitor total/unique visits, referrers, operating systems, devices, browsers, and top browser languages in real-time.
|
||||
- 📈 **Comprehensive Analytics Dashboard** — Monitor total/unique visits, referrers, operating systems, devices, browsers, and top browser languages in real-time with full cross-filtering (e.g., "show only mobile visits from Poland").
|
||||
- ⚡ **Live Activity Feed (new in v5.0.0)** — Dedicated real-time visit stream page at `/stats/live`. Polls only when new visits are detected (O(1) `MAX(id)` check + `skipRender()` on no-change), serving `~100-byte` responses when idle. Flags via `flagcdn.com`, precomputed in PHP, CSP-safe Alpine.js fallback.
|
||||
- 🗂️ **Folders & Tags (new in v5.0.0)** — Organize links into folders (one folder per link) and tag them with up to 5 labels. Navigate directly from a folder or tag to a filtered link list. Link counts displayed on each folder and tag card.
|
||||
- 🗄️ **Link Archiving (new in v5.0.0)** — Archive unused links instead of permanently deleting them. Archived links are hidden from the default view but can be restored at any time.
|
||||
- 🛡️ **VPN, Proxy & Bot Filtering** — Exclude scrapers, crawlers, Tor exit nodes, and automated bot clicks to keep your analytics clean and accurate.
|
||||
- 🔍 **Google Safe Browsing** — Automatically scan target URLs on creation/edit to block phishing, malware, and social engineering links.
|
||||
- 🎨 **SVG QR Code Designer** — Customize dot styles, gradients, margins, and upload custom brand logos with auto-clear backing dots and high-quality SVG/PNG exports.
|
||||
@@ -70,7 +73,7 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
|
||||
- 🛡️ **Throttling & Rate Limiting** — Protect your redirection routes from flood attacks with configurable per-IP rate limits.
|
||||
- 📊 **Log Aggregation & Pruning** — Compact millions of raw visit logs into daily summaries automatically to prevent database bloat.
|
||||
- 🎯 **Central Retargeting Pixel Registry (new in v3.0.0)** — Register Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, and Pinterest Tag centrally and associate them with links via checkboxes.
|
||||
- 🔌 **Developer REST API (updated in v3.5.0)** — Full programmatical control with secure API Key authentication to create, read, update, list, delete, and inspect analytics for short links externally.
|
||||
- 🔌 **Developer REST API with Scoped Keys (updated in v5.0.0)** — Full programmatical control with API Key authentication. Each key supports `links:read-only` or `links:read-write` scope, and an individual per-key rate limit (requests/minute). Create, read, update, list, delete, and inspect analytics for short links externally.
|
||||
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
|
||||
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
|
||||
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
|
||||
@@ -807,9 +810,26 @@ For security, new API keys are hashed using SHA-256 and stored securely in the d
|
||||
|
||||
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
|
||||
|
||||
### API Key Scopes
|
||||
|
||||
Each API key can be assigned a **scope** that restricts its access level:
|
||||
|
||||
| Scope | Allowed Methods | Use Case |
|
||||
|---|---|---|
|
||||
| `links:read-write` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | Full control (default) |
|
||||
| `links:read-only` | `GET` only | Integrations that only need to read link data (e.g., dashboards, reporting tools) |
|
||||
|
||||
A `links:read-only` key attempting a write operation (`POST`, `PUT`, `PATCH`, `DELETE`) will receive a `403 Forbidden` response.
|
||||
|
||||
### Per-Key Rate Limiting
|
||||
|
||||
Each API key can have its own **individual rate limit** (requests per minute), independent of the global route throttle. This allows high-trust integrations to use a higher limit while restricting untrusted keys more aggressively.
|
||||
|
||||
Configure the rate limit for each key in **Settings → API & Webhooks → Developer API Keys**. The default is **60 requests per minute**.
|
||||
|
||||
### Endpoints
|
||||
|
||||
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware and rate-limited to **60 requests per minute** (`throttle:60,1`).
|
||||
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware. The effective rate limit is the lower of the global route throttle and the per-key rate limit.
|
||||
|
||||
#### `GET /api/short-url/links`
|
||||
List all short URLs (paginated, 30 per page).
|
||||
@@ -1394,6 +1414,28 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v5.0.0
|
||||
|
||||
#### Analytics — Live Activity Feed
|
||||
- **Dedicated `/stats/live` page** — Live Feed is now a full standalone page at `/{record}/stats/live`, on par with Visit Logs at `/{record}/stats/logs`. Tab navigation is consistent across all three stat sub-pages (Statistics / Live Feed / Visit Logs).
|
||||
- **Intelligent no-change skip** — Each poll tick now calls `checkForUpdates()`, which performs a single O(1) `MAX(id)` query against the composite index. If the highest visit ID hasn't changed since the last render, `skipRender()` is called and Livewire returns a ~100-byte "nothing changed" response — zero PHP rendering, zero DOM operations.
|
||||
- **Correct cursor initialization** — `$latestVisitId` is initialized to `-1` (sentinel). After the first `getViewData()` call it is set to the actual `MAX(id)`, so the very first poll does not trigger an unnecessary re-render.
|
||||
- **Cache key versioned by `latestVisitId`** — Fixed a bug where two concurrent users detecting the same new visit could share a thundering-herd cache entry that didn't include the visit that triggered their render, causing them to permanently miss that visit until the next new visit arrived.
|
||||
- **3-second thundering-herd cache** — Collapses N concurrent admin users watching the same URL into one DB query per 3-second window. Cache key includes URL id, date range, active filters, and `latestVisitId` version.
|
||||
- **Country flags via `flagcdn.com`** — Flag images are rendered as `<img src="https://flagcdn.com/h20/{cc}.webp">` instead of emoji. The URL is precomputed once in PHP (`getViewData()`), not per-row in Blade. Alpine.js `x-on:error` handles broken images without CSP-unsafe inline `onerror` handlers.
|
||||
- **Blade performance** — `time_ago` is precomputed once in PHP using `$visit->visited_at->diffForHumans()` (no `Carbon::parse()` double-instantiation). All per-row presentation values are plain PHP arrays, not Eloquent model calls in Blade.
|
||||
- **Optimized DB query** — Selects only 12 needed columns (`id`, `visited_at`, `country`, `country_code`, `city`, `browser`, `operating_system`, `device_type`, `referer_host`, `referer_url`, `ip_address`, `is_qr_scan`, `selected_variant`) instead of `SELECT *`. Limit reduced to 25 rows.
|
||||
- **Polling stops when hidden** — `wire:poll.5s.visible` pauses polling completely when the widget is scrolled out of viewport.
|
||||
|
||||
#### Link Organization
|
||||
- **Folders** — Each short URL can be assigned to one folder. Clicking a folder in the sidebar navigates to a filtered link list showing only that folder's links. Link count displayed per folder.
|
||||
- **Tags** — Each short URL can have up to 5 tags. Clicking a tag shows a filtered link list. Link counts displayed per tag.
|
||||
- **Archiving** — Short URLs can be archived instead of permanently deleted. Archived links are hidden from the default list view and can be restored at any time via the Filament panel.
|
||||
|
||||
#### REST API
|
||||
- **Per-key API scopes** — Each API key now supports either `links:read-only` (GET only) or `links:read-write` (full CRUD) scope. Read-only keys return `403 Forbidden` on any write attempt.
|
||||
- **Per-key rate limiting** — Each API key can have its own individual rate limit (requests per minute), independent of the global route throttle. Configure in **Settings → API & Webhooks → Developer API Keys**.
|
||||
|
||||
### v4.0.0
|
||||
- **Custom Domains Branding** — Let users connect branded custom domains with real-time CNAME/A record DNS verification and automatic prefix-free root routing.
|
||||
- **Cache Leak Fix** — Solved a critical caching bug where single-use links could be visited multiple times during the cache TTL by passing the host-specific key suffix to cache invalidations.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
|
||||
@@ -265,11 +266,10 @@ return [
|
||||
|
|
||||
*/
|
||||
'user' => [
|
||||
'model' => \App\Models\User::class,
|
||||
'model' => User::class,
|
||||
'name_column' => 'name',
|
||||
'email_column' => 'email',
|
||||
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_url_folders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->string('color')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('short_url_tags', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->string('color')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('short_url_tag', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('short_url_id')->index();
|
||||
$table->unsignedBigInteger('tag_id')->index();
|
||||
$table->primary(['short_url_id', 'tag_id']);
|
||||
});
|
||||
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('folder_id')->nullable()->after('custom_domain_id')->index();
|
||||
$table->boolean('is_archived')->default(false)->after('is_enabled')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['folder_id', 'is_archived']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('short_url_tag');
|
||||
Schema::dropIfExists('short_url_tags');
|
||||
Schema::dropIfExists('short_url_folders');
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss/theme";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
@source "../../src/**/*.php";
|
||||
@source "../../resources/views/**/*.blade.php";
|
||||
@source "../views/**/*.blade.php";
|
||||
@source "packages/filament-short-url/src/**/*.php";
|
||||
@source "packages/filament-short-url/resources/views/**/*.blade.php";
|
||||
|
||||
.short-url-card {
|
||||
position: relative !important;
|
||||
@@ -85,4 +88,38 @@ select.fi-select-input:disabled,
|
||||
.fi-select-input .fi-select-input-btn.fi-disabled {
|
||||
background-image: none !important;
|
||||
padding-right: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Folder Card overrides to reset default Filament record-content padding and force height/width stretch */
|
||||
.folder-card .fi-ta-record-content,
|
||||
.folder-card .fi-ta-record-content-ctn {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.folder-card .fi-ta-record-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Position action buttons absolute on the card wrapper to keep dropdown aligned */
|
||||
.folder-card .fi-ta-actions {
|
||||
position: absolute !important;
|
||||
top: 1rem !important;
|
||||
right: 1rem !important;
|
||||
z-index: 20 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Force height down the Filament table layout tree so justify-between works */
|
||||
.folder-card .fi-ta-stack,
|
||||
.folder-card .fi-ta-stack > div,
|
||||
.folder-card .fi-ta-text,
|
||||
.folder-card .fi-ta-text > div {
|
||||
height: 100% !important;
|
||||
}
|
||||
2
resources/dist/filament-short-url.css
vendored
2
resources/dist/filament-short-url.css
vendored
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Links',
|
||||
'navigation_group' => 'Tools',
|
||||
'navigation_group' => 'URL Shortener',
|
||||
'resource_title' => 'Short URL',
|
||||
'empty_state_heading' => 'No links yet',
|
||||
'empty_state_description' => 'Start creating short links for your marketing campaigns, referral programs, and more.',
|
||||
@@ -156,6 +156,7 @@ return [
|
||||
'url_key_change_confirmation_heading' => 'Replace link?',
|
||||
'url_key_change_confirmation' => "You've modified the back-half of this link's URL. Saving these changes will update the key. Any references to the original link will break.",
|
||||
'action_share' => 'Share Link',
|
||||
'action_move' => 'Move',
|
||||
'share_title' => 'Share Link',
|
||||
'share_description' => 'Share this short link via:',
|
||||
'share_copy' => 'Copy',
|
||||
@@ -674,5 +675,76 @@ return [
|
||||
'action_delete_domain' => 'Delete Domain',
|
||||
'delete_domain_confirmation_desc' => 'Are you sure you want to delete this custom domain? This action cannot be undone – proceed with caution.',
|
||||
'short_link_label' => 'Short Link',
|
||||
];
|
||||
|
||||
// API Scopes & Rate Limits
|
||||
'api_key_scope' => 'API Scope',
|
||||
'api_key_scope_helper' => 'Choose the permission level for this API key.',
|
||||
'api_key_scope_read_write' => 'Read & Write (Full Access)',
|
||||
'api_key_scope_read_only' => 'Read Only',
|
||||
'api_key_rate_limit' => 'Rate Limit',
|
||||
'api_key_rate_limit_helper' => 'Define the maximum requests per minute allowed for this key.',
|
||||
'api_key_rate_limit_unlimited' => 'Unlimited',
|
||||
'api_key_rate_limit_rpm' => ':count requests / min',
|
||||
|
||||
// Folders
|
||||
'folders_navigation_label' => 'Folders',
|
||||
'folder_resource_title' => 'Folder',
|
||||
'folder_name' => 'Folder Name',
|
||||
'folder_slug' => 'Slug',
|
||||
'folder_color' => 'Color',
|
||||
'empty_state_folder_action' => 'Create Folder',
|
||||
'empty_state_folders_heading' => 'No folders yet',
|
||||
'empty_state_folders_description' => 'Organize your short links into folders.',
|
||||
'action_edit_folder' => 'Edit Folder',
|
||||
'action_delete_folder' => 'Delete Folder',
|
||||
'link_count_one' => '1 link',
|
||||
'link_count_many' => ':count links',
|
||||
|
||||
// Tags
|
||||
'tags_navigation_label' => 'Tags',
|
||||
'tag_resource_title' => 'Tag',
|
||||
'tag_name' => 'Tag Name',
|
||||
'tag_slug' => 'Slug',
|
||||
'tag_color' => 'Color',
|
||||
'empty_state_tag_action' => 'Create Tag',
|
||||
'empty_state_tags_heading' => 'No tags yet',
|
||||
'empty_state_tags_description' => 'Tag your short links to filter them easily.',
|
||||
'action_edit_tag' => 'Edit Tag',
|
||||
'action_delete_tag' => 'Delete Tag',
|
||||
|
||||
// Archiving
|
||||
'is_archived' => 'Archived',
|
||||
'is_archived_helper' => 'Archived links are hidden from the active list but continue to redirect.',
|
||||
'action_archive' => 'Archive',
|
||||
'action_restore' => 'Restore',
|
||||
'action_archive_selected' => 'Archive Selected',
|
||||
'action_restore_selected' => 'Restore Selected',
|
||||
'tab_active_links' => 'Active',
|
||||
'tab_archived_links' => 'Archived',
|
||||
|
||||
// Live Feed
|
||||
'stats_tab_live_feed' => 'Live Feed',
|
||||
'stats_live_feed_empty' => 'No visits recorded yet. Visits will appear here in real-time.',
|
||||
'stats_live_feed_poll_interval' => 'Updates every 5s',
|
||||
'stats_col_visitor' => 'Visitor / Location',
|
||||
|
||||
// Empty state mock values
|
||||
'empty_state_tags_mock_redirecting' => 'Redirecting...',
|
||||
'empty_state_tags_mock_promo' => 'promo',
|
||||
'empty_state_tags_mock_social' => 'social',
|
||||
'empty_state_tags_mock_dev' => 'dev',
|
||||
|
||||
// Folder links count
|
||||
'folder_one_link' => '1 link',
|
||||
'folder_many_links' => ':count links',
|
||||
|
||||
// Colors
|
||||
'color_gray' => 'Gray',
|
||||
'color_red' => 'Red',
|
||||
'color_blue' => 'Blue',
|
||||
'color_green' => 'Green',
|
||||
'color_yellow' => 'Yellow',
|
||||
'color_indigo' => 'Indigo',
|
||||
'color_purple' => 'Purple',
|
||||
'color_pink' => 'Pink',
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Linki',
|
||||
'navigation_group' => 'Narzędzia',
|
||||
'navigation_group' => 'Skracacz linków',
|
||||
'resource_title' => 'Krótki URL',
|
||||
'empty_state_heading' => 'Brak linków',
|
||||
'empty_state_description' => 'Zacznij tworzyć krótkie linki dla swoich kampanii marketingowych, programów partnerskich i nie tylko.',
|
||||
@@ -152,6 +152,7 @@ return [
|
||||
'url_key_change_confirmation_heading' => 'Zastąpić link?',
|
||||
'url_key_change_confirmation' => 'Zmieniłeś krótki klucz (Short Key) tego linku. Zapisanie tych zmian spowoduje, że dotychczasowy krótki URL przestanie działać, a wszelkie udostępnione wcześniej linki z poprzednim kluczem wygasną.',
|
||||
'action_share' => 'Udostępnij link',
|
||||
'action_move' => 'Przenieś',
|
||||
'share_title' => 'Udostępnij Link',
|
||||
'share_description' => 'Udostępnij ten krótki link przez:',
|
||||
'share_copy' => 'Skopiuj',
|
||||
@@ -672,5 +673,76 @@ return [
|
||||
'action_delete_domain' => 'Usuń domenę',
|
||||
'delete_domain_confirmation_desc' => 'Czy na pewno chcesz usunąć tę własną domenę? Ta akcja nie może zostać cofnięta – postępuj ostrożnie.',
|
||||
'short_link_label' => 'Krótki link',
|
||||
];
|
||||
|
||||
// API Scopes & Rate Limits
|
||||
'api_key_scope' => 'Uprawnienia (Scope)',
|
||||
'api_key_scope_helper' => 'Wybierz poziom dostępu dla tego klucza API.',
|
||||
'api_key_scope_read_write' => 'Odczyt i Zapis (Pełny dostęp)',
|
||||
'api_key_scope_read_only' => 'Tylko Odczyt (Read-Only)',
|
||||
'api_key_rate_limit' => 'Limit zapytań',
|
||||
'api_key_rate_limit_helper' => 'Zdefiniuj maksymalną liczbę zapytań na minutę dopuszczalną dla tego klucza.',
|
||||
'api_key_rate_limit_unlimited' => 'Bez limitu',
|
||||
'api_key_rate_limit_rpm' => ':count zapytań / min',
|
||||
|
||||
// Folders
|
||||
'folders_navigation_label' => 'Foldery',
|
||||
'folder_resource_title' => 'Folder',
|
||||
'folder_name' => 'Nazwa folderu',
|
||||
'folder_slug' => 'Uproszczona nazwa (Slug)',
|
||||
'folder_color' => 'Kolor',
|
||||
'empty_state_folder_action' => 'Utwórz folder',
|
||||
'empty_state_folders_heading' => 'Brak folderów',
|
||||
'empty_state_folders_description' => 'Uporządkuj swoje krótkie linki w folderach.',
|
||||
'action_edit_folder' => 'Edytuj folder',
|
||||
'action_delete_folder' => 'Usuń folder',
|
||||
'link_count_one' => '1 link',
|
||||
'link_count_many' => ':count linków',
|
||||
|
||||
// Tagi
|
||||
'tags_navigation_label' => 'Tagi',
|
||||
'tag_resource_title' => 'Tag',
|
||||
'tag_name' => 'Nazwa tagu',
|
||||
'tag_slug' => 'Uproszczona nazwa (Slug)',
|
||||
'tag_color' => 'Kolor',
|
||||
'empty_state_tag_action' => 'Utwórz tag',
|
||||
'empty_state_tags_heading' => 'Brak tagów',
|
||||
'empty_state_tags_description' => 'Oznaczaj swoje krótkie linki tagami, by łatwo je filtrować.',
|
||||
'action_edit_tag' => 'Edytuj tag',
|
||||
'action_delete_tag' => 'Usuń tag',
|
||||
|
||||
// Archiving
|
||||
'is_archived' => 'Zarchiwizowany',
|
||||
'is_archived_helper' => 'Zarchiwizowane linki są ukrywane z głównej listy, ale nadal przekierowują użytkowników.',
|
||||
'action_archive' => 'Archiwizuj',
|
||||
'action_restore' => 'Przywróć',
|
||||
'action_archive_selected' => 'Zarchiwizuj zaznaczone',
|
||||
'action_restore_selected' => 'Przywróć zaznaczone',
|
||||
'tab_active_links' => 'Aktywne',
|
||||
'tab_archived_links' => 'Zarchiwizowane',
|
||||
|
||||
// Live Feed
|
||||
'stats_tab_live_feed' => 'Strumień aktywności',
|
||||
'stats_live_feed_empty' => 'Brak wizyt. Nowe wizyty pojawią się tutaj w czasie rzeczywistym.',
|
||||
'stats_live_feed_poll_interval' => 'Aktualizacja co 5s',
|
||||
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
|
||||
|
||||
// Puste stany - makiety
|
||||
'empty_state_tags_mock_redirecting' => 'Przekierowywanie...',
|
||||
'empty_state_tags_mock_promo' => 'promocja',
|
||||
'empty_state_tags_mock_social' => 'social-media',
|
||||
'empty_state_tags_mock_dev' => 'programisci',
|
||||
|
||||
// Liczba linków w folderze
|
||||
'folder_one_link' => '1 link',
|
||||
'folder_many_links' => ':count linków',
|
||||
|
||||
// Colors
|
||||
'color_gray' => 'Szary',
|
||||
'color_red' => 'Czerwony',
|
||||
'color_blue' => 'Niebieski',
|
||||
'color_green' => 'Zielony',
|
||||
'color_yellow' => 'Żółty',
|
||||
'color_indigo' => 'Indygo',
|
||||
'color_purple' => 'Fioletowy',
|
||||
'color_pink' => 'Różowy',
|
||||
];
|
||||
|
||||
35
resources/views/live.blade.php
Normal file
35
resources/views/live.blade.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::tabs class="mb-6">
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}"
|
||||
icon="heroicon-m-presentation-chart-line"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
|
||||
icon="heroicon-m-list-bullet"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_visit_logs') }}
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div class="mt-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLiveFeedWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => null,
|
||||
'dateTo' => null,
|
||||
'filters' => [],
|
||||
], key('live-feed-page-' . $record->id))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -8,6 +8,14 @@
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
icon="heroicon-m-list-bullet"
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::tabs class="mb-6">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
wire:click="$set('activeTab', 'statistics')"
|
||||
:active="$activeTab === 'statistics'"
|
||||
icon="heroicon-m-presentation-chart-line"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
|
||||
@@ -127,3 +136,4 @@
|
||||
], key('stats-variants-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
|
||||
133
resources/views/table/empty-state-folders.blade.php
Normal file
133
resources/views/table/empty-state-folders.blade.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
|
||||
<!-- Folders Visual Grid -->
|
||||
<div class="relative flex items-center justify-center h-40 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
|
||||
|
||||
<!-- 3D Folder Container -->
|
||||
<div class="relative w-48 h-32 flex items-center justify-center z-10" style="perspective: 1000px;">
|
||||
<!-- Folder Back -->
|
||||
<div class="absolute bottom-2 w-28 h-18 bg-neutral-300 dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-400/30 dark:border-neutral-700/50" style="transform: translateZ(-30px);">
|
||||
<!-- Folder Tab -->
|
||||
<div class="absolute -top-2 left-3 w-10 h-3 bg-neutral-300 dark:bg-neutral-800 rounded-t-md border-t border-x border-neutral-400/30 dark:border-neutral-700/50"></div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Document/Link Cards (Middle Layer) -->
|
||||
<!-- Card 1 -->
|
||||
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-blue-500/90 to-indigo-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
|
||||
style="animation: fsu-folder-card-1 4s infinite cubic-bezier(0.25, 1, 0.5, 1); transform-style: preserve-3d;">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
|
||||
</div>
|
||||
<div class="h-1 w-12 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2 -->
|
||||
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-violet-500/90 to-purple-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
|
||||
style="animation: fsu-folder-card-2 4s infinite cubic-bezier(0.25, 1, 0.5, 1); animation-delay: 2s; transform-style: preserve-3d;">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
|
||||
</div>
|
||||
<div class="h-1 w-12 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Front (Glassmorphic & Tilted) -->
|
||||
<div class="absolute bottom-2 w-28 h-16 bg-neutral-100/50 dark:bg-neutral-900/50 border border-white/30 dark:border-neutral-800/80 rounded-lg shadow-lg origin-bottom transition-all"
|
||||
style="backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transform: rotateX(-16deg); transform-style: preserve-3d; animation: fsu-folder-front 4s infinite ease-in-out; box-shadow: 0 12px 20px -8px rgba(0,0,0,0.15), 0 4px 6px -2px rgba(0,0,0,0.05);">
|
||||
<!-- Folder lock / branding line -->
|
||||
<div class="absolute top-2 left-3 w-6 h-1 bg-neutral-300 dark:bg-neutral-700 rounded-full"></div>
|
||||
<div class="absolute bottom-2 right-3 w-3 h-3 rounded-full border border-neutral-300 dark:border-neutral-750 flex items-center justify-center">
|
||||
<div class="w-1 h-1 rounded-full bg-neutral-400 dark:bg-neutral-650"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fsu-folder-front {
|
||||
0%, 100% {
|
||||
transform: rotateX(-15deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotateX(-22deg);
|
||||
}
|
||||
}
|
||||
@keyframes fsu-folder-card-1 {
|
||||
0% {
|
||||
transform: translate3d(35px, -60px, 10px) rotate(12deg) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
transform: translate3d(20px, -45px, 20px) rotate(6deg) scale(0.95);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate3d(0px, 10px, -15px) rotate(-2deg) scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
75%, 100% {
|
||||
transform: translate3d(0px, 25px, -25px) rotate(-4deg) scale(0.75);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fsu-folder-card-2 {
|
||||
0% {
|
||||
transform: translate3d(-35px, -60px, 10px) rotate(-12deg) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
transform: translate3d(-20px, -45px, 20px) rotate(-6deg) scale(0.95);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate3d(0px, 10px, -15px) rotate(2deg) scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
75%, 100% {
|
||||
transform: translate3d(0px, 25px, -25px) rotate(4deg) scale(0.75);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="max-w-md text-pretty text-center px-4">
|
||||
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ __('filament-short-url::default.empty_state_folders_heading') }}
|
||||
</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_folders_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
@if(!isset($hideCreateButton) || !$hideCreateButton)
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
x-on:click="$wire.mountAction('create')"
|
||||
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
|
||||
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.empty_state_folder_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
128
resources/views/table/empty-state-tags.blade.php
Normal file
128
resources/views/table/empty-state-tags.blade.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
|
||||
<!-- Tags Visual Grid -->
|
||||
<div class="relative flex items-center justify-center h-40 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
|
||||
|
||||
<!-- Orbiting Tags Container -->
|
||||
<div class="relative w-64 h-32 flex items-center justify-center z-10">
|
||||
<!-- Orbit Path Visual -->
|
||||
<svg class="absolute w-56 h-20 opacity-20 dark:opacity-30 text-neutral-300 dark:text-neutral-700" viewBox="0 0 220 80" fill="none">
|
||||
<ellipse cx="110" cy="40" rx="100" ry="32" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 4" />
|
||||
</svg>
|
||||
|
||||
<!-- Central URL Card -->
|
||||
<div class="relative w-36 bg-white dark:bg-neutral-900 border border-neutral-200/80 dark:border-neutral-800 rounded-xl px-2.5 py-2 shadow-md flex items-center gap-2 select-none"
|
||||
style="animation: fsu-tag-center-card-float 4s infinite ease-in-out; z-index: 10;">
|
||||
<div class="w-5 h-5 rounded-lg bg-purple-500/10 dark:bg-purple-500/20 flex items-center justify-center text-purple-600 dark:text-purple-400 flex-shrink-0">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<span class="text-[10px] font-bold text-neutral-800 dark:text-neutral-200 font-mono leading-none truncate max-w-[85px] block" title="{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}">
|
||||
{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}
|
||||
</span>
|
||||
<span class="text-[7.5px] text-neutral-400 dark:text-neutral-500 font-sans mt-0.5">{{ __('filament-short-url::default.empty_state_tags_mock_redirecting') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orbiting Badges -->
|
||||
<!-- Tag 1: promo (Purple) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-purple-100 dark:bg-purple-950/60 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: 0s;">
|
||||
<span class="text-purple-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tag 2: social (Teal) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-teal-100 dark:bg-teal-950/60 text-teal-700 dark:text-teal-300 border border-teal-200 dark:border-teal-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -2.66s;">
|
||||
<span class="text-teal-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_social') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tag 3: dev (Amber) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-amber-100 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -5.33s;">
|
||||
<span class="text-amber-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_dev') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fsu-tag-center-card-float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(-1deg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) rotate(1deg);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
@keyframes fsu-tag-orbit {
|
||||
0% {
|
||||
transform: translate3d(-100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
4% {
|
||||
z-index: 20;
|
||||
}
|
||||
25% {
|
||||
transform: translate3d(0px, 32px, 0px) scale(1.05);
|
||||
z-index: 20;
|
||||
opacity: 1;
|
||||
}
|
||||
46% {
|
||||
z-index: 20;
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
54% {
|
||||
z-index: 2;
|
||||
}
|
||||
75% {
|
||||
transform: translate3d(0px, -32px, 0px) scale(0.8);
|
||||
z-index: 2;
|
||||
opacity: 0.5;
|
||||
}
|
||||
96% {
|
||||
z-index: 2;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(-100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="max-w-md text-pretty text-center px-4">
|
||||
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ __('filament-short-url::default.empty_state_tags_heading') }}
|
||||
</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_tags_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
@if(!isset($hideCreateButton) || !$hideCreateButton)
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
x-on:click="$wire.mountAction('create')"
|
||||
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
|
||||
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tag_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
107
resources/views/table/folder-card-column.blade.php
Normal file
107
resources/views/table/folder-card-column.blade.php
Normal file
@@ -0,0 +1,107 @@
|
||||
@php
|
||||
$record = $getRecord();
|
||||
$color = $record->color ?? 'gray';
|
||||
|
||||
// Map folder color to valid Tailwind classes matching the premium demo design
|
||||
$colorClasses = match ($color) {
|
||||
'gray' => [
|
||||
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
|
||||
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
|
||||
'text' => 'text-neutral-600 dark:text-neutral-400',
|
||||
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
|
||||
],
|
||||
'red' => [
|
||||
'border_outer' => 'border-red-200 dark:border-red-900/30',
|
||||
'bg_inner' => 'bg-red-100 dark:bg-red-950/40',
|
||||
'text' => 'text-red-800 dark:text-red-400',
|
||||
'badge_bg' => 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-350',
|
||||
],
|
||||
'blue' => [
|
||||
'border_outer' => 'border-blue-200 dark:border-blue-900/30',
|
||||
'bg_inner' => 'bg-blue-100 dark:bg-blue-950/40',
|
||||
'text' => 'text-blue-800 dark:text-blue-400',
|
||||
'badge_bg' => 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-350',
|
||||
],
|
||||
'green' => [
|
||||
'border_outer' => 'border-emerald-200 dark:border-emerald-900/30',
|
||||
'bg_inner' => 'bg-emerald-100 dark:bg-emerald-950/40',
|
||||
'text' => 'text-emerald-800 dark:text-emerald-400',
|
||||
'badge_bg' => 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-350',
|
||||
],
|
||||
'yellow' => [
|
||||
'border_outer' => 'border-amber-200 dark:border-amber-900/30',
|
||||
'bg_inner' => 'bg-amber-100 dark:bg-amber-950/40',
|
||||
'text' => 'text-amber-800 dark:text-amber-400',
|
||||
'badge_bg' => 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-350',
|
||||
],
|
||||
'indigo' => [
|
||||
'border_outer' => 'border-indigo-200 dark:border-indigo-900/30',
|
||||
'bg_inner' => 'bg-indigo-100 dark:bg-indigo-950/40',
|
||||
'text' => 'text-indigo-800 dark:text-indigo-400',
|
||||
'badge_bg' => 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-350',
|
||||
],
|
||||
'purple' => [
|
||||
'border_outer' => 'border-purple-200 dark:border-purple-900/30',
|
||||
'bg_inner' => 'bg-purple-100 dark:bg-purple-950/40',
|
||||
'text' => 'text-purple-800 dark:text-purple-400',
|
||||
'badge_bg' => 'bg-purple-100 dark:bg-purple-900/30 text-purple-750 dark:text-purple-350',
|
||||
],
|
||||
'pink' => [
|
||||
'border_outer' => 'border-pink-200 dark:border-pink-900/30',
|
||||
'bg_inner' => 'bg-pink-100 dark:bg-pink-950/40',
|
||||
'text' => 'text-pink-800 dark:text-pink-400',
|
||||
'badge_bg' => 'bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-350',
|
||||
],
|
||||
default => [
|
||||
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
|
||||
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
|
||||
'text' => 'text-neutral-600 dark:text-neutral-400',
|
||||
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
|
||||
],
|
||||
};
|
||||
|
||||
$linkCount = $record->short_urls_count ?? 0;
|
||||
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
|
||||
if (str_contains($linkString, 'filament-short-url::')) {
|
||||
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col justify-between h-full w-full select-none">
|
||||
|
||||
<!-- Top Row: Icon Container -->
|
||||
<div class="flex items-center justify-between z-10 pointer-events-none">
|
||||
<div class="border rounded-full bg-white dark:bg-neutral-850 p-0.5 {{ $colorClasses['border_outer'] }}">
|
||||
<div class="rounded-full p-2 {{ $colorClasses['bg_inner'] }} {{ $colorClasses['text'] }}">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row: Title, Badges, Link count -->
|
||||
<div class="z-10 pointer-events-none">
|
||||
<span class="flex items-center justify-start gap-1.5 truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
<span class="truncate">{{ $record->name }}</span>
|
||||
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded {{ $colorClasses['badge_bg'] }} font-mono">
|
||||
/{{ $record->slug }}
|
||||
</span>
|
||||
@if($record->slug === 'default' || $record->slug === 'links')
|
||||
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
|
||||
Default
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<div class="mt-1.5 flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
|
||||
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5">
|
||||
<g fill="currentColor">
|
||||
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="text-sm font-normal">{{ $linkString }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
53
resources/views/table/tag-row-column.blade.php
Normal file
53
resources/views/table/tag-row-column.blade.php
Normal file
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
$record = $getRecord();
|
||||
$color = $record->color ?? 'gray';
|
||||
|
||||
// Map tag color to icon + badge colors — same hex values as the Select options
|
||||
$colorMap = match ($color) {
|
||||
'red' => ['icon_bg' => '#fee2e2', 'icon_color' => '#ef4444'],
|
||||
'blue' => ['icon_bg' => '#dbeafe', 'icon_color' => '#3b82f6'],
|
||||
'green' => ['icon_bg' => '#d1fae5', 'icon_color' => '#10b981'],
|
||||
'yellow' => ['icon_bg' => '#fef3c7', 'icon_color' => '#f59e0b'],
|
||||
'indigo' => ['icon_bg' => '#e0e7ff', 'icon_color' => '#6366f1'],
|
||||
'purple' => ['icon_bg' => '#f3e8ff', 'icon_color' => '#a855f7'],
|
||||
'pink' => ['icon_bg' => '#fce7f3', 'icon_color' => '#ec4899'],
|
||||
default => ['icon_bg' => '#f3f4f6', 'icon_color' => '#737373'],
|
||||
};
|
||||
|
||||
$linkCount = $record->short_urls_count ?? 0;
|
||||
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
|
||||
if (str_contains($linkString, 'filament-short-url::')) {
|
||||
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-3 select-none py-0.5">
|
||||
{{-- Left: icon + name --}}
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{{-- Colored tag icon --}}
|
||||
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full"
|
||||
style="background-color: {{ $colorMap['icon_bg'] }};">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="{{ $colorMap['icon_color'] }}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2H7a2 2 0 0 0-2 2v5.586a1 1 0 0 0 .293.707l9.414 9.414a2 2 0 0 0 2.828 0l4.172-4.172a2 2 0 0 0 0-2.828L12.707 2.293A1 1 0 0 0 12 2z"/>
|
||||
<circle cx="7.5" cy="7.5" r="1" fill="{{ $colorMap['icon_color'] }}" stroke="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Name --}}
|
||||
<span class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{{ $record->name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Right: link count badge --}}
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 mr-2 border border-neutral-200 dark:border-neutral-800 rounded-full px-2.5 py-1 text-neutral-500 dark:text-neutral-400 text-xs font-medium bg-white dark:bg-neutral-900">
|
||||
<svg height="14" width="14" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 flex-shrink-0">
|
||||
<g fill="currentColor">
|
||||
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<span>{{ $linkString }}</span>
|
||||
</div>
|
||||
</div>
|
||||
153
resources/views/widgets/live-feed.blade.php
Normal file
153
resources/views/widgets/live-feed.blade.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<x-filament-widgets::widget>
|
||||
{{--
|
||||
wire:poll calls checkForUpdates() instead of blindly re-rendering.
|
||||
checkForUpdates() does a single MAX(id) query; if nothing changed it
|
||||
calls skipRender() and Livewire returns a ~100-byte "no diff" response.
|
||||
Only when a new visit is detected does the full render happen.
|
||||
.visible stops polling entirely when the widget is scrolled out of view.
|
||||
--}}
|
||||
<div wire:poll.5s.visible="checkForUpdates"
|
||||
class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-2 w-2 relative">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{{ __('filament-short-url::default.stats_live_feed_poll_interval') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6 relative min-h-[300px]">
|
||||
<!-- Spinner shown only during re-renders that actually happen -->
|
||||
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4" wire:loading.class="opacity-40 pointer-events-none transition-opacity">
|
||||
@forelse ($visits as $visit)
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 border border-gray-100 dark:border-white/5 rounded-xl hover:bg-gray-50/50 dark:hover:bg-white/5 transition-all">
|
||||
|
||||
<!-- Visitor & Location Info -->
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
{{-- Flag from flagcdn.com. Alpine handles broken image gracefully without
|
||||
inline onerror JS (which can violate CSP nonce policies). --}}
|
||||
<div class="mt-0.5 shrink-0 w-6 flex items-center" title="{{ $visit['country'] }}">
|
||||
@if ($visit['flag_url'])
|
||||
<span x-data="{ error: false }">
|
||||
<img x-show="!error"
|
||||
x-on:error="error = true"
|
||||
src="{{ $visit['flag_url'] }}"
|
||||
width="20"
|
||||
height="15"
|
||||
alt="{{ $visit['country_code'] }}"
|
||||
class="rounded-[2px] object-cover shadow-sm">
|
||||
<span x-show="error" class="text-base leading-none">🌐</span>
|
||||
</span>
|
||||
@else
|
||||
<span class="text-base leading-none">🌐</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ $visit['country_code'] }}' })"
|
||||
class="text-sm font-semibold text-gray-950 dark:text-white hover:underline truncate">
|
||||
{{ $visit['city'] ?: 'Unknown City' }}, {{ $visit['country'] ?: 'Unknown Country' }}
|
||||
</button>
|
||||
|
||||
@if ($visit['is_qr_scan'])
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300 border border-purple-100 dark:border-purple-900/40">
|
||||
<x-filament::icon icon="heroicon-m-qr-code" class="w-3.5 h-3.5" />
|
||||
QR Scan
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($visit['selected_variant'])
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 border border-amber-100 dark:border-amber-900/40">
|
||||
Variant: {{ $visit['selected_variant'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1 flex items-center gap-2">
|
||||
{{-- time_ago precomputed in PHP — no Carbon::parse() per-row in Blade --}}
|
||||
<span>{{ $visit['time_ago'] }}</span>
|
||||
<span>•</span>
|
||||
<span class="font-mono text-[11px]">{{ $visit['ip_address'] ?: '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrer -->
|
||||
<div class="flex items-center gap-2 min-w-[150px] max-w-[220px]">
|
||||
<x-filament::icon icon="heroicon-m-link" class="w-4 h-4 text-gray-400 dark:text-gray-500 shrink-0" />
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 truncate"
|
||||
title="{{ $visit['referer_url'] }}">
|
||||
{{ $visit['referer_host'] ?: __('filament-short-url::default.stats_referer_direct') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Device / Browser / OS filters -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Device -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ $visit['device_type'] }}' })"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by device: {{ $visit['device_type'] }}">
|
||||
@if (strtolower($visit['device_type'] ?? '') === 'desktop')
|
||||
<x-filament::icon icon="heroicon-m-computer-desktop" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($visit['device_type'] ?? '') === 'mobile')
|
||||
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($visit['device_type'] ?? '') === 'tablet')
|
||||
<x-filament::icon icon="heroicon-m-device-tablet" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@else
|
||||
<x-filament::icon icon="heroicon-m-question-mark-circle" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@endif
|
||||
<span class="capitalize">{{ $visit['device_type'] ?: 'Unknown' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Browser -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ $visit['browser'] }}' })"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by browser: {{ $visit['browser'] }}">
|
||||
<x-filament::icon icon="heroicon-m-globe-alt" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
<span>{{ $visit['browser'] ?: 'Browser' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- OS -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ $visit['operating_system'] }}' })"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by OS: {{ $visit['operating_system'] }}">
|
||||
<x-filament::icon icon="heroicon-m-cpu-chip" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
<span>{{ $visit['operating_system'] ?: 'OS' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-chat-bubble-left-right" class="h-6 w-6" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_live_feed_empty') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
@@ -25,7 +25,6 @@ Route::match(
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([
|
||||
AuthenticateShortUrlApi::class,
|
||||
'throttle:60,1',
|
||||
])
|
||||
->group(function () {
|
||||
Route::get('links', [ShortUrlApiController::class, 'index']);
|
||||
|
||||
@@ -27,12 +27,22 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
default => 'DATE(visited_at)',
|
||||
};
|
||||
|
||||
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
|
||||
$dates = ShortUrlVisit::where('visited_at', '<', $today)
|
||||
->selectRaw("{$dateExpression} as visit_date")
|
||||
->distinct()
|
||||
->pluck('visit_date')
|
||||
->toArray();
|
||||
// 1. Find the oldest and newest visit dates before today using highly optimized min/max index scanning
|
||||
$oldestVisit = ShortUrlVisit::where('visited_at', '<', $today)->min('visited_at');
|
||||
if (! $oldestVisit) {
|
||||
$dates = [];
|
||||
} else {
|
||||
$latestVisit = ShortUrlVisit::where('visited_at', '<', $today)->max('visited_at');
|
||||
$startCarbon = Carbon::parse($oldestVisit)->startOfDay();
|
||||
$endCarbon = Carbon::parse($latestVisit)->startOfDay();
|
||||
|
||||
$dates = [];
|
||||
$current = $startCarbon->copy();
|
||||
while ($current->lte($endCarbon)) {
|
||||
$dates[] = $current->toDateString();
|
||||
$current->addDay();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($dates)) {
|
||||
$this->info('No historical visits to aggregate.');
|
||||
|
||||
159
src/Filament/Resources/ShortUrlFolderResource.php
Normal file
159
src/Filament/Resources/ShortUrlFolderResource.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages\ListShortUrlFolders;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ShortUrlFolderResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlFolder::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
|
||||
|
||||
protected static ?int $navigationSort = 53;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folders_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folder_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folders_navigation_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 3;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_folders', 'slug', ignoreRecord: true),
|
||||
|
||||
Select::make('color')
|
||||
->label(__('filament-short-url::default.folder_color'))
|
||||
->allowHtml()
|
||||
->options([
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>' . __('filament-short-url::default.color_gray') . '</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>' . __('filament-short-url::default.color_red') . '</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>' . __('filament-short-url::default.color_blue') . '</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>' . __('filament-short-url::default.color_green') . '</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>' . __('filament-short-url::default.color_yellow') . '</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>' . __('filament-short-url::default.color_indigo') . '</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>' . __('filament-short-url::default.color_purple') . '</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>' . __('filament-short-url::default.color_pink') . '</span></span>',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Stack::make([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->view('filament-short-url::table.folder-card-column')
|
||||
->counts('shortUrls')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->extraAttributes([
|
||||
'class' => 'h-full',
|
||||
]),
|
||||
])->extraAttributes([
|
||||
'class' => 'h-full',
|
||||
]),
|
||||
])
|
||||
->contentGrid([
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->recordUrl(
|
||||
fn (ShortUrlFolder $record): string => ShortUrlResource::getUrl('index', [
|
||||
'filters' => ['folder' => ['values' => [$record->id]]],
|
||||
])
|
||||
)
|
||||
->recordClasses(fn (): string => 'folder-card relative flex flex-col justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-5 py-4 transition-all duration-200 h-[120px] sm:h-[132px] group/card hover:shadow-md hover:border-neutral-350 dark:hover:border-neutral-700')
|
||||
->filters([])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->label(__('filament-short-url::default.action_edit_folder'))
|
||||
->modalWidth('md'),
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->label(__('filament-short-url::default.action_delete_folder')),
|
||||
])
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->extraAttributes([
|
||||
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-transparent hover:bg-bg-muted data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 w-8 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
|
||||
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
|
||||
]),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-folders'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlFolders::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlFolders extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlFolderResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_folder_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_folder_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false)
|
||||
->createAnother(false),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ListShortUrls;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLiveFeed;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLogs;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortUrlForm;
|
||||
@@ -111,6 +112,7 @@ class ShortUrlResource extends Resource
|
||||
return [
|
||||
'index' => ListShortUrls::route('/'),
|
||||
'stats' => ViewShortUrlStats::route('/{record}/stats'),
|
||||
'stats.live' => ViewShortUrlLiveFeed::route('/{record}/stats/live'),
|
||||
'stats.logs' => ViewShortUrlLogs::route('/{record}/stats/logs'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
@@ -395,4 +396,14 @@ HTML;
|
||||
ShortUrlGlobalOverview::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'active' => Tab::make(__('filament-short-url::default.tab_active_links'))
|
||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', false)),
|
||||
'archived' => Tab::make(__('filament-short-url::default.tab_archived_links'))
|
||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', true)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +53,29 @@ class DeveloperTab
|
||||
->disabled()
|
||||
->dehydrated()
|
||||
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
|
||||
Select::make('scope')
|
||||
->label(__('filament-short-url::default.api_key_scope'))
|
||||
->options([
|
||||
'links:read-write' => __('filament-short-url::default.api_key_scope_read_write'),
|
||||
'links:read-only' => __('filament-short-url::default.api_key_scope_read_only'),
|
||||
])
|
||||
->default('links:read-write')
|
||||
->required(),
|
||||
Select::make('rate_limit')
|
||||
->label(__('filament-short-url::default.api_key_rate_limit'))
|
||||
->options([
|
||||
'60' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 60]),
|
||||
'120' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 120]),
|
||||
'300' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 300]),
|
||||
'0' => __('filament-short-url::default.api_key_rate_limit_unlimited'),
|
||||
])
|
||||
->default('60')
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.active'))
|
||||
->default(true),
|
||||
])
|
||||
->columns(3)
|
||||
->columns(5)
|
||||
->default([]),
|
||||
]),
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 3;
|
||||
return ShortUrlResource::getNavigationSort() + 99;
|
||||
} catch (\Throwable) {
|
||||
return 53;
|
||||
return 99;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\Page;
|
||||
|
||||
class ViewShortUrlLiveFeed extends Page
|
||||
{
|
||||
protected static string $resource = ShortUrlResource::class;
|
||||
|
||||
protected string $view = 'filament-short-url::live';
|
||||
|
||||
protected static ?string $title = 'Live Feed';
|
||||
|
||||
public ShortUrl $record;
|
||||
|
||||
public function mount(ShortUrl $record): void
|
||||
{
|
||||
$this->record = $record;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return [
|
||||
'record' => $this->record,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('back')
|
||||
->label(__('filament-short-url::default.stats_btn_back'))
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url(ShortUrlResource::getUrl()),
|
||||
|
||||
Action::make('copy_url')
|
||||
->label(__('filament-short-url::default.stats_btn_copy'))
|
||||
->icon('heroicon-o-clipboard')
|
||||
->color('gray')
|
||||
->extraAttributes([
|
||||
'x-on:click' => 'navigator.clipboard.writeText("'.$this->record->getShortUrl().'")',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('filament-short-url::default.stats_tab_live_feed').' — '.$this->record->url_key;
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumm::make('referer_host')
|
||||
Tables\Columns\TextColumn::make('referer_host')
|
||||
->label(__('filament-short-url::default.stats_col_referrer'))
|
||||
->icon('heroicon-o-link')
|
||||
->formatStateUsing(fn ($state) => empty($state) ? __('filament-short-url::default.stats_referer_direct') : $state)
|
||||
|
||||
@@ -32,6 +32,8 @@ class ViewShortUrlStats extends Page implements HasForms
|
||||
|
||||
public array $activeFilters = [];
|
||||
|
||||
public string $activeTab = 'statistics';
|
||||
|
||||
#[On('set-stats-filter')]
|
||||
public function setStatsFilter(string $key, $value): void
|
||||
{
|
||||
|
||||
@@ -341,6 +341,88 @@ class LinkTab
|
||||
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.folders_navigation_label').' & '.__('filament-short-url::default.tags_navigation_label'))
|
||||
->schema([
|
||||
Select::make('folder_id')
|
||||
->label(__('filament-short-url::default.folder_resource_title'))
|
||||
->relationship('folder', 'name')
|
||||
->nullable()
|
||||
->searchable()
|
||||
->preload()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_folders', 'slug'),
|
||||
Select::make('color')
|
||||
->label(__('filament-short-url::default.folder_color'))
|
||||
->options([
|
||||
'gray' => 'Gray',
|
||||
'red' => 'Red',
|
||||
'blue' => 'Blue',
|
||||
'green' => 'Green',
|
||||
'yellow' => 'Yellow',
|
||||
'indigo' => 'Indigo',
|
||||
'purple' => 'Purple',
|
||||
'pink' => 'Pink',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
]),
|
||||
|
||||
Select::make('tags')
|
||||
->label(__('filament-short-url::default.tags_navigation_label'))
|
||||
->multiple()
|
||||
->maxItems(5)
|
||||
->relationship('tags', 'name')
|
||||
->preload()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.tag_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.tag_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_tags', 'slug'),
|
||||
Select::make('color')
|
||||
->label(__('filament-short-url::default.tag_color'))
|
||||
->options([
|
||||
'gray' => 'Gray',
|
||||
'red' => 'Red',
|
||||
'blue' => 'Blue',
|
||||
'green' => 'Green',
|
||||
'yellow' => 'Yellow',
|
||||
'indigo' => 'Indigo',
|
||||
'purple' => 'Purple',
|
||||
'pink' => 'Pink',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
]),
|
||||
|
||||
Toggle::make('is_archived')
|
||||
->label(__('filament-short-url::default.is_archived'))
|
||||
->helperText(__('filament-short-url::default.is_archived_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->hiddenOn('create')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
|
||||
Textarea::make('notes')
|
||||
->label(__('filament-short-url::default.notes'))
|
||||
|
||||
@@ -60,6 +60,18 @@ class ShortUrlsTable
|
||||
'0' => 'Multi-use',
|
||||
'1' => 'Single-use',
|
||||
]),
|
||||
|
||||
SelectFilter::make('folder')
|
||||
->label(__('filament-short-url::default.folder_resource_title'))
|
||||
->relationship('folder', 'name')
|
||||
->preload()
|
||||
->multiple(),
|
||||
|
||||
SelectFilter::make('tags')
|
||||
->label(__('filament-short-url::default.tags_navigation_label'))
|
||||
->relationship('tags', 'name')
|
||||
->preload()
|
||||
->multiple(),
|
||||
])
|
||||
->recordClasses(fn (): string => 'short-url-card group/card')
|
||||
->actions([
|
||||
@@ -143,6 +155,64 @@ class ShortUrlsTable
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
ActionGroup::make([
|
||||
Action::make('move')
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_move').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">M</span></div>'))
|
||||
->icon('heroicon-o-folder-open')
|
||||
->modalHeading(fn (ShortUrl $record) => __('filament-short-url::default.action_move') . ' ' . str_replace(['http://', 'https://'], '', $record->getShortUrl()))
|
||||
->modalWidth('md')
|
||||
->modalSubmitActionLabel(__('filament-short-url::default.action_move'))
|
||||
->fillForm(fn (ShortUrl $record): array => [
|
||||
'folder_id' => $record->folder_id,
|
||||
])
|
||||
->form(fn (ShortUrl $record): array => [
|
||||
Forms\Components\Select::make('folder_id')
|
||||
->label(__('filament-short-url::default.folder_resource_title'))
|
||||
->relationship('folder', 'name')
|
||||
->preload()
|
||||
->searchable()
|
||||
->createOptionForm([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', \Illuminate\Support\Str::slug($state))),
|
||||
Forms\Components\TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_folders', 'slug'),
|
||||
Forms\Components\Select::make('color')
|
||||
->label(__('filament-short-url::default.folder_color'))
|
||||
->options([
|
||||
'gray' => 'Gray',
|
||||
'red' => 'Red',
|
||||
'blue' => 'Blue',
|
||||
'green' => 'Green',
|
||||
'yellow' => 'Yellow',
|
||||
'indigo' => 'Indigo',
|
||||
'purple' => 'Purple',
|
||||
'pink' => 'Pink',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
])
|
||||
->action(function (ShortUrl $record, array $data): void {
|
||||
$record->update(['folder_id' => $data['folder_id']]);
|
||||
}),
|
||||
|
||||
Action::make('archive')
|
||||
->label(fn (ShortUrl $record) => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.($record->is_archived ? __('filament-short-url::default.action_restore') : __('filament-short-url::default.action_archive')).'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">'.($record->is_archived ? 'R' : 'A').'</span></div>'))
|
||||
->icon(fn (ShortUrl $record) => $record->is_archived
|
||||
? 'heroicon-o-arrow-path-rounded-square'
|
||||
: 'heroicon-o-archive-box')
|
||||
->color('warning')
|
||||
->action(fn (ShortUrl $record) => $record->update(['is_archived' => ! $record->is_archived])),
|
||||
])->dropdown(false),
|
||||
|
||||
ActionGroup::make([
|
||||
DeleteAction::make()
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left text-red-600 dark:text-red-400 font-semibold"><span>'.__('filament-short-url::default.action_delete').'</span><span class="text-[10px] bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 rounded px-1.5 py-0.5 ml-auto font-mono font-normal">X</span></div>'))
|
||||
@@ -247,11 +317,25 @@ class ShortUrlsTable
|
||||
->action(fn ($records) => $records->each->update(['is_enabled' => false]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('archive')
|
||||
->label(__('filament-short-url::default.action_archive_selected'))
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('warning')
|
||||
->action(fn ($records) => $records->each->update(['is_archived' => true]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('restore')
|
||||
->label(__('filament-short-url::default.action_restore_selected'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('success')
|
||||
->action(fn ($records) => $records->each->update(['is_archived' => false]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['user', 'customDomain']))
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['user', 'customDomain', 'folder', 'tags']))
|
||||
->recordUrl(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record]))
|
||||
->emptyState(view('filament-short-url::table.empty-state'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlLiveFeedWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
/**
|
||||
* ID of the most recently rendered visit.
|
||||
* Stored in the Livewire snapshot so each browser tab tracks independently.
|
||||
* Initialized to -1 so mount can detect "first load" vs "subsequent polls".
|
||||
*/
|
||||
public int $latestVisitId = -1;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.live-feed';
|
||||
|
||||
/**
|
||||
* Called on every wire:poll tick.
|
||||
*
|
||||
* Does a single O(1) MAX(id) query against the composite index.
|
||||
* If nothing changed since the last render → skipRender() → ~100-byte response.
|
||||
* Only when the cursor advances does a full re-render occur.
|
||||
*/
|
||||
public function checkForUpdates(): void
|
||||
{
|
||||
if (! $this->record) {
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latest = (int) ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->max('id');
|
||||
|
||||
if ($latest === $this->latestVisitId) {
|
||||
// Nothing new — skip rendering entirely.
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// New visit detected — update the cursor; render is triggered automatically.
|
||||
$this->latestVisitId = $latest;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return ['visits' => []];
|
||||
}
|
||||
|
||||
// BUG FIX #1: Include latestVisitId in the cache key.
|
||||
// Without this, user B could get a 3-second-old cache response that doesn't
|
||||
// include the visit that triggered their re-render, then skipRender() on every
|
||||
// subsequent poll because their latestVisitId is already up-to-date — causing
|
||||
// them to permanently miss that visit until the next new visit arrives.
|
||||
$filtersHash = md5(json_encode($this->filters ?? []));
|
||||
$cacheKey = "live_feed_{$this->record->id}_{$this->dateFrom}_{$this->dateTo}_{$filtersHash}_{$this->latestVisitId}";
|
||||
|
||||
$visits = cache()->remember($cacheKey, 3, function () {
|
||||
$query = ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false);
|
||||
|
||||
if ($this->dateFrom) {
|
||||
$query->where('visited_at', '>=', $this->dateFrom.' 00:00:00');
|
||||
}
|
||||
if ($this->dateTo) {
|
||||
$query->where('visited_at', '<=', $this->dateTo.' 23:59:59');
|
||||
}
|
||||
|
||||
$this->record->applyStatsFilters($query, $this->filters ?? []);
|
||||
|
||||
return $query
|
||||
->latest('visited_at')
|
||||
->limit(25)
|
||||
->get([
|
||||
'id', 'visited_at', 'country', 'country_code', 'city',
|
||||
'browser', 'operating_system', 'device_type',
|
||||
'referer_host', 'referer_url', 'ip_address',
|
||||
'is_qr_scan', 'selected_variant',
|
||||
])
|
||||
->map(fn (ShortUrlVisit $visit) => [
|
||||
'id' => $visit->id,
|
||||
'time_ago' => $visit->visited_at->diffForHumans(),
|
||||
'ip_address' => $visit->ip_address,
|
||||
'flag_url' => $visit->country_code
|
||||
? 'https://flagcdn.com/h20/'.strtolower($visit->country_code).'.webp'
|
||||
: null,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'browser' => $visit->browser,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'device_type' => $visit->device_type,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'is_qr_scan' => $visit->is_qr_scan,
|
||||
'selected_variant' => $visit->selected_variant,
|
||||
])
|
||||
->all();
|
||||
});
|
||||
|
||||
// BUG FIX #2: Initialize the cursor on first load so the very first poll
|
||||
// does not trigger an unnecessary re-render.
|
||||
// $latestVisitId starts at -1 (sentinel). After the initial getViewData()
|
||||
// we set it to the actual MAX id so checkForUpdates() correctly skips
|
||||
// re-renders when there are no new visits.
|
||||
if ($this->latestVisitId === -1) {
|
||||
$this->latestVisitId = ! empty($visits)
|
||||
? (int) $visits[0]['id']
|
||||
: (int) ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->max('id');
|
||||
}
|
||||
|
||||
return ['visits' => $visits];
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,10 @@ class ShortUrlWorldMapWidget extends Widget
|
||||
->where('is_proxy', false);
|
||||
|
||||
if ($dateFromClean) {
|
||||
$query->whereDate('visited_at', '>=', $dateFromClean);
|
||||
$query->where('visited_at', '>=', $dateFromClean.' 00:00:00');
|
||||
}
|
||||
if ($dateToClean) {
|
||||
$query->whereDate('visited_at', '<=', $dateToClean);
|
||||
$query->where('visited_at', '<=', $dateToClean.' 23:59:59');
|
||||
}
|
||||
|
||||
$this->record->applyStatsFilters($query, $this->filters);
|
||||
|
||||
143
src/Filament/Resources/ShortUrlTagResource.php
Normal file
143
src/Filament/Resources/ShortUrlTagResource.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource\Pages\ListShortUrlTags;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ShortUrlTagResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlTag::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-tag';
|
||||
|
||||
protected static ?int $navigationSort = 54;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.tags_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.tag_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.tags_navigation_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 4;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.tag_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.tag_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_tags', 'slug', ignoreRecord: true),
|
||||
|
||||
Select::make('color')
|
||||
->label(__('filament-short-url::default.tag_color'))
|
||||
->allowHtml()
|
||||
->options([
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>' . __('filament-short-url::default.color_gray') . '</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>' . __('filament-short-url::default.color_red') . '</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>' . __('filament-short-url::default.color_blue') . '</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>' . __('filament-short-url::default.color_green') . '</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>' . __('filament-short-url::default.color_yellow') . '</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>' . __('filament-short-url::default.color_indigo') . '</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>' . __('filament-short-url::default.color_purple') . '</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>' . __('filament-short-url::default.color_pink') . '</span></span>',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.tag_name'))
|
||||
->view('filament-short-url::table.tag-row-column')
|
||||
->counts('shortUrls')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
])
|
||||
->recordUrl(
|
||||
fn (ShortUrlTag $record): string => ShortUrlResource::getUrl('index', [
|
||||
'filters' => ['tags' => ['values' => [$record->id]]],
|
||||
])
|
||||
)
|
||||
->filters([])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->label(__('filament-short-url::default.action_edit_tag'))
|
||||
->modalWidth('md'),
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->label(__('filament-short-url::default.action_delete_tag')),
|
||||
])
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-tags'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlTags::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlTags extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlTagResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_tag_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_tag_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false)
|
||||
->createAnother(false),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
@@ -55,6 +57,8 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
ShortUrlResource::class,
|
||||
ShortUrlPixelResource::class,
|
||||
ShortUrlCustomDomainResource::class,
|
||||
ShortUrlFolderResource::class,
|
||||
ShortUrlTagResource::class,
|
||||
])
|
||||
->pages([
|
||||
ShortUrlSettingsPage::class,
|
||||
|
||||
@@ -42,6 +42,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
'2026_06_05_000001_add_user_id_to_short_urls_table',
|
||||
'2026_06_05_000002_create_short_url_custom_domains_table',
|
||||
'2026_06_05_000003_add_custom_domain_id_to_short_urls_table',
|
||||
'2026_06_06_000001_create_short_url_folders_and_tags_tables',
|
||||
])
|
||||
->hasCommands([
|
||||
SyncBufferedCountersCommand::class,
|
||||
|
||||
@@ -11,14 +11,12 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
use Bjanczak\FilamentShortUrl\Http\Requests\StoreShortUrlRequest;
|
||||
use Bjanczak\FilamentShortUrl\Http\Requests\UpdateShortUrlRequest;
|
||||
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ShortUrlApiController extends Controller
|
||||
{
|
||||
@@ -55,9 +53,6 @@ class ShortUrlApiController extends Controller
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
// Fire 'created' webhook if active
|
||||
$this->dispatchCreatedWebhook($shortUrl);
|
||||
|
||||
return (new ShortUrlResource($shortUrl))
|
||||
->additional(['message' => 'Short URL created successfully.'])
|
||||
->response()
|
||||
@@ -149,47 +144,4 @@ class ShortUrlApiController extends Controller
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch webhook if global or per-link webhook is active for 'created' event.
|
||||
*/
|
||||
private function dispatchCreatedWebhook(ShortUrl $shortUrl): void
|
||||
{
|
||||
$targetUrl = $shortUrl->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
if (empty($targetUrl) && ! empty($globalUrl) && in_array('created', $events)) {
|
||||
$targetUrl = $globalUrl;
|
||||
}
|
||||
|
||||
if (! empty($targetUrl)) {
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'created',
|
||||
payload: [
|
||||
'event' => 'created',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => (new ShortUrlResource($shortUrl))->resolve(),
|
||||
]
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
$job->onConnection($connection);
|
||||
} else {
|
||||
$job->onConnection('sync');
|
||||
}
|
||||
|
||||
dispatch($job);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Created webhook dispatch failed', [
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@ class ShortUrlRedirectController extends Controller
|
||||
|
||||
// Redirect to custom expiration URL if defined, otherwise 410 Gone if disabled or expired
|
||||
if (! $shortUrl->isActive()) {
|
||||
if ($shortUrl->isExpired() || ($shortUrl->deactivated_at && $shortUrl->deactivated_at->isPast())) {
|
||||
$expiredKey = "fsu:expired-webhook-sent:{$shortUrl->id}";
|
||||
if (cache()->add($expiredKey, true, 86400 * 30)) {
|
||||
$shortUrl->dispatchWebhook('expired');
|
||||
}
|
||||
}
|
||||
|
||||
if ($shortUrl->expiration_redirect_url && $shortUrl->is_enabled) {
|
||||
return redirect()->away($shortUrl->expiration_redirect_url, 302);
|
||||
}
|
||||
@@ -193,7 +200,7 @@ class ShortUrlRedirectController extends Controller
|
||||
$selectedVariant = app()->bound('resolved_ab_variant') ? app('resolved_ab_variant') : null;
|
||||
|
||||
$job = new TrackShortUrlVisitJob(
|
||||
shortUrl: $shortUrl,
|
||||
shortUrlId: $shortUrl->id,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $request->userAgent() ?? '',
|
||||
refererUrl: $request->header('Referer'),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Http\Middleware;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateShortUrlApi
|
||||
@@ -41,6 +42,7 @@ class AuthenticateShortUrlApi
|
||||
$keys = $this->mgr->get('api_keys', []);
|
||||
|
||||
$valid = false;
|
||||
$matchedKey = null;
|
||||
$hashedInput = hash('sha256', $apiKey);
|
||||
|
||||
foreach ($keys as $keyObj) {
|
||||
@@ -53,23 +55,52 @@ class AuthenticateShortUrlApi
|
||||
if ($storedHash) {
|
||||
if (hash_equals($storedHash, $hashedInput)) {
|
||||
$valid = true;
|
||||
$matchedKey = $keyObj;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$storedPlain = $keyObj['key'] ?? '';
|
||||
if ($storedPlain !== '' && hash_equals($storedPlain, $apiKey)) {
|
||||
$valid = true;
|
||||
$matchedKey = $keyObj;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $valid) {
|
||||
if (! $valid || ! $matchedKey) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized. Invalid or inactive API Key.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
// 1. API Scope Authorization (backward compatible with read-write)
|
||||
$scope = $matchedKey['scope'] ?? 'links:read-write';
|
||||
if ($scope === 'links:read-only' && ! $request->isMethod('GET')) {
|
||||
return response()->json([
|
||||
'error' => 'Forbidden. This API key has read-only permissions.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. Per-Key Rate Limiting (default to 60 rpm if unspecified, 0 is Unlimited)
|
||||
$rateLimit = isset($matchedKey['rate_limit']) ? (int) $matchedKey['rate_limit'] : 60;
|
||||
if ($rateLimit > 0) {
|
||||
$keyIdentifier = $matchedKey['hashed_key'] ?? hash('sha256', $apiKey);
|
||||
$limiterKey = "fsu_api_key_limit:{$keyIdentifier}";
|
||||
|
||||
if (RateLimiter::tooManyAttempts($limiterKey, $rateLimit)) {
|
||||
$retryAfter = RateLimiter::availableIn($limiterKey);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Too many requests. API key rate limit exceeded.',
|
||||
], 429, [
|
||||
'Retry-After' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::hit($limiterKey, 60);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
public int $backoff = 5;
|
||||
|
||||
public function __construct(
|
||||
public readonly ShortUrl $shortUrl,
|
||||
public readonly int $shortUrlId,
|
||||
public readonly string $ipAddress,
|
||||
public readonly string $userAgent,
|
||||
public readonly ?string $refererUrl = null,
|
||||
@@ -56,7 +56,7 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
public function handle(ShortUrlTracker $tracker): void
|
||||
{
|
||||
// Re-fetch fresh model to avoid acting on stale state
|
||||
$shortUrl = ShortUrl::find($this->shortUrl->id);
|
||||
$shortUrl = ShortUrl::find($this->shortUrlId);
|
||||
|
||||
if (! $shortUrl || ! $shortUrl->track_visits) {
|
||||
return;
|
||||
@@ -102,71 +102,39 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
// Fire event for user listeners
|
||||
ShortUrlVisited::dispatch($shortUrl, $visit);
|
||||
|
||||
// Trigger Webhook if active
|
||||
$targetUrl = $shortUrl->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
$webhooksToDispatch = [];
|
||||
if (! empty($targetUrl)) {
|
||||
$webhooksToDispatch[] = $targetUrl;
|
||||
}
|
||||
if (! empty($globalUrl) && in_array('visited', $events)) {
|
||||
$webhooksToDispatch[] = $globalUrl;
|
||||
}
|
||||
|
||||
if (! empty($webhooksToDispatch)) {
|
||||
$payload = [
|
||||
'event' => 'visited',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => [
|
||||
'id' => $shortUrl->id,
|
||||
'destination_url' => $shortUrl->destination_url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'short_url' => $shortUrl->getShortUrl(),
|
||||
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
|
||||
'unique_visits' => (int) $shortUrl->unique_visits,
|
||||
],
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'visited_at' => $visit->visited_at->toIso8601String(),
|
||||
'device_type' => $visit->device_type,
|
||||
'browser' => $visit->browser,
|
||||
'browser_version' => $visit->browser_version,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'operating_system_version' => $visit->operating_system_version,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'utm_source' => $visit->utm_source,
|
||||
'utm_medium' => $visit->utm_medium,
|
||||
'utm_campaign' => $visit->utm_campaign,
|
||||
'utm_term' => $visit->utm_term,
|
||||
'utm_content' => $visit->utm_content,
|
||||
'is_qr_scan' => (bool) $visit->is_qr_scan,
|
||||
'browser_language' => $visit->browser_language,
|
||||
],
|
||||
];
|
||||
|
||||
foreach (array_unique($webhooksToDispatch) as $url) {
|
||||
try {
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $url,
|
||||
event: 'visited',
|
||||
payload: $payload
|
||||
)->onConnection($this->connection ?: 'sync'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Visited webhook dispatch failed', [
|
||||
'url' => $url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
// Check if limit is reached now
|
||||
if ($shortUrl->max_visits !== null && $shortUrl->getRealTimeTotalVisits() >= $shortUrl->max_visits) {
|
||||
$limitReachedKey = "fsu:limit-reached-webhook-sent:{$shortUrl->id}";
|
||||
if (cache()->add($limitReachedKey, true, 86400 * 30)) {
|
||||
$shortUrl->dispatchWebhook('limit_reached');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger Webhook if active
|
||||
$shortUrl->dispatchWebhook('visited', [
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'visited_at' => $visit->visited_at->toIso8601String(),
|
||||
'device_type' => $visit->device_type,
|
||||
'browser' => $visit->browser,
|
||||
'browser_version' => $visit->browser_version,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'operating_system_version' => $visit->operating_system_version,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'utm_source' => $visit->utm_source,
|
||||
'utm_medium' => $visit->utm_medium,
|
||||
'utm_campaign' => $visit->utm_campaign,
|
||||
'utm_term' => $visit->utm_term,
|
||||
'utm_content' => $visit->utm_content,
|
||||
'is_qr_scan' => (bool) $visit->is_qr_scan,
|
||||
'browser_language' => $visit->browser_language,
|
||||
],
|
||||
]);
|
||||
|
||||
// Optional GA4 Measurement Protocol integration
|
||||
if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) {
|
||||
$this->sendGa4Hit($shortUrl, $visit);
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -21,6 +23,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -95,13 +98,17 @@ class ShortUrl extends Model
|
||||
'destination_type',
|
||||
'rotation_variants',
|
||||
'custom_domain_id',
|
||||
'folder_id',
|
||||
'is_archived',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'custom_domain_id' => 'integer',
|
||||
'folder_id' => 'integer',
|
||||
'is_enabled' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
'single_use' => 'boolean',
|
||||
'forward_query_params' => 'boolean',
|
||||
'auto_open_app_mobile' => 'boolean',
|
||||
@@ -127,6 +134,16 @@ class ShortUrl extends Model
|
||||
|
||||
// ─── Relations ───────────────────────────────────────────────────────────
|
||||
|
||||
public function folder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShortUrlFolder::class, 'folder_id');
|
||||
}
|
||||
|
||||
public function tags(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrlTag::class, 'short_url_tag', 'short_url_id', 'tag_id');
|
||||
}
|
||||
|
||||
public function customDomain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShortUrlCustomDomain::class, 'custom_domain_id');
|
||||
@@ -300,8 +317,11 @@ class ShortUrl extends Model
|
||||
}
|
||||
|
||||
// Current domain
|
||||
if ($m->custom_domain_id && $m->customDomain) {
|
||||
$hosts[] = $m->customDomain->domain;
|
||||
if ($m->custom_domain_id) {
|
||||
$domain = ShortUrlCustomDomain::find($m->custom_domain_id);
|
||||
if ($domain) {
|
||||
$hosts[] = $domain->domain;
|
||||
}
|
||||
}
|
||||
|
||||
// If custom domain changed, clear old domain cache too
|
||||
@@ -333,6 +353,7 @@ class ShortUrl extends Model
|
||||
// Using wasRecentlyCreated avoids the double-forget that the separate created() event caused.
|
||||
if ($m->wasRecentlyCreated) {
|
||||
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
|
||||
$m->dispatchWebhook('created');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -346,8 +367,11 @@ class ShortUrl extends Model
|
||||
$hosts[] = $appHost;
|
||||
}
|
||||
|
||||
if ($m->custom_domain_id && $m->customDomain) {
|
||||
$hosts[] = $m->customDomain->domain;
|
||||
if ($m->custom_domain_id) {
|
||||
$domain = ShortUrlCustomDomain::find($m->custom_domain_id);
|
||||
if ($domain) {
|
||||
$hosts[] = $domain->domain;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
@@ -484,4 +508,69 @@ class ShortUrl extends Model
|
||||
|
||||
return rtrim($baseUrl, '/').'/'.$this->url_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch webhook if global or per-link webhook is active for the specified event.
|
||||
*/
|
||||
public function dispatchWebhook(string $event, array $extraPayload = []): void
|
||||
{
|
||||
$targetUrl = $this->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
$webhooksToDispatch = [];
|
||||
if (! empty($targetUrl)) {
|
||||
$webhooksToDispatch[] = $targetUrl;
|
||||
}
|
||||
if (! empty($globalUrl) && in_array($event, $events)) {
|
||||
$webhooksToDispatch[] = $globalUrl;
|
||||
}
|
||||
|
||||
if (empty($webhooksToDispatch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shortUrlData = ($event === 'created')
|
||||
? (new ShortUrlResource($this))->resolve()
|
||||
: [
|
||||
'id' => $this->id,
|
||||
'destination_url' => $this->destination_url,
|
||||
'url_key' => $this->url_key,
|
||||
'short_url' => $this->getShortUrl(),
|
||||
'total_visits' => (int) $this->getRealTimeTotalVisits(),
|
||||
'unique_visits' => (int) $this->unique_visits,
|
||||
];
|
||||
|
||||
$payload = array_merge([
|
||||
'event' => $event,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => $shortUrlData,
|
||||
], $extraPayload);
|
||||
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
|
||||
foreach (array_unique($webhooksToDispatch) as $url) {
|
||||
try {
|
||||
$job = new SendWebhookJob(
|
||||
url: $url,
|
||||
event: $event,
|
||||
payload: $payload
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
$job->onConnection($connection);
|
||||
} else {
|
||||
$job->onConnection('sync');
|
||||
}
|
||||
|
||||
dispatch($job);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("[FilamentShortUrl] {$event} webhook dispatch failed", [
|
||||
'url' => $url,
|
||||
'url_key' => $this->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
50
src/Models/ShortUrlFolder.php
Normal file
50
src/Models/ShortUrlFolder.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ShortUrlFolder extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'color',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $m) {
|
||||
if (auth()->check() && empty($m->user_id)) {
|
||||
$m->user_id = auth()->id();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function shortUrls(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShortUrl::class, 'folder_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(
|
||||
config('filament-short-url.user.model', User::class),
|
||||
'user_id'
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/Models/ShortUrlTag.php
Normal file
55
src/Models/ShortUrlTag.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class ShortUrlTag extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'color',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $m) {
|
||||
if (auth()->check() && empty($m->user_id)) {
|
||||
$m->user_id = auth()->id();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function shortUrls(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
ShortUrl::class,
|
||||
'short_url_tag',
|
||||
'tag_id',
|
||||
'short_url_id'
|
||||
);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(
|
||||
config('filament-short-url.user.model', User::class),
|
||||
'user_id'
|
||||
);
|
||||
}
|
||||
}
|
||||
140
tests/Feature/ShortUrlApiKeyScopingTest.php
Normal file
140
tests/Feature/ShortUrlApiKeyScopingTest.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
beforeEach(function () {
|
||||
// Enable REST API and inject mock keys with different scopes and rate limits
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'api_enabled' => true,
|
||||
'api_keys' => [
|
||||
[
|
||||
'name' => 'Read Only Token',
|
||||
'key' => 'sh_key_read_only',
|
||||
'scope' => 'links:read-only',
|
||||
'rate_limit' => '60',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Read Write Token',
|
||||
'key' => 'sh_key_read_write',
|
||||
'scope' => 'links:read-write',
|
||||
'rate_limit' => '0', // Unlimited
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Rate Limited Token',
|
||||
'key' => 'sh_key_rate_limited',
|
||||
'scope' => 'links:read-write',
|
||||
'rate_limit' => '2', // 2 per minute
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Legacy Token',
|
||||
'key' => 'sh_key_legacy',
|
||||
// No scope, no rate_limit (should default to links:read-write and 60 rpm)
|
||||
'is_active' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Clear rate limiter cache before each test
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_rate_limited'));
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_read_only'));
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_read_write'));
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_legacy'));
|
||||
});
|
||||
|
||||
it('allows GET requests but blocks modifying requests with read-only scope', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/scope-test',
|
||||
'url_key' => 'scopetest',
|
||||
]);
|
||||
|
||||
// GET should work
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(200);
|
||||
|
||||
// POST should be blocked
|
||||
$this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'googleapi',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(403)
|
||||
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
|
||||
|
||||
// PUT should be blocked
|
||||
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(403)
|
||||
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
|
||||
|
||||
// DELETE should be blocked
|
||||
$this->deleteJson("/api/short-url/links/{$shortUrl->id}", [], [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(403)
|
||||
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
|
||||
});
|
||||
|
||||
it('allows modifying requests with read-write scope', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/scope-test',
|
||||
'url_key' => 'scopetest',
|
||||
]);
|
||||
|
||||
// PUT should work
|
||||
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_read_write',
|
||||
])->assertStatus(200);
|
||||
});
|
||||
|
||||
it('defaults legacy tokens to read-write and 60 rpm', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/scope-test',
|
||||
'url_key' => 'scopetest',
|
||||
]);
|
||||
|
||||
// Legacy token has no scope, so should default to read-write and succeed
|
||||
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_legacy',
|
||||
])->assertStatus(200);
|
||||
});
|
||||
|
||||
it('enforces dynamic rate limiting per API key', function () {
|
||||
// Request 1: Allowed
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_rate_limited',
|
||||
])->assertStatus(200);
|
||||
|
||||
// Request 2: Allowed
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_rate_limited',
|
||||
])->assertStatus(200);
|
||||
|
||||
// Request 3: Blocked
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_rate_limited',
|
||||
]);
|
||||
|
||||
$response->assertStatus(429)
|
||||
->assertJsonFragment(['error' => 'Too many requests. API key rate limit exceeded.'])
|
||||
->assertHeader('Retry-After');
|
||||
});
|
||||
|
||||
it('does not limit unlimited keys (rate_limit = 0)', function () {
|
||||
// Run multiple requests to exceed standard limit
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_read_write',
|
||||
])->assertStatus(200);
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,6 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -127,7 +126,7 @@ describe('complex option interactions', function () {
|
||||
// 1. Unauthenticated request as mobile: password prompt must show first, not warning, not redirect
|
||||
$response = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')
|
||||
->get('/s/tgt-pw-warn');
|
||||
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertSee('Password Required')
|
||||
->assertDontSee('Security Redirect Warning')
|
||||
@@ -190,7 +189,7 @@ describe('complex option interactions', function () {
|
||||
// Verification:
|
||||
// 1. The old visit should be pruned from database
|
||||
$this->assertDatabaseMissing('short_url_visits', ['id' => $oldVisit->id]);
|
||||
|
||||
|
||||
// 2. The recent visit should remain in database
|
||||
$this->assertDatabaseHas('short_url_visits', ['id' => $recentVisit->id]);
|
||||
|
||||
@@ -243,7 +242,7 @@ describe('complex option interactions', function () {
|
||||
'destination_type' => 'single',
|
||||
'url' => 'https://tablet-single.com',
|
||||
'filters' => [['type' => 'device', 'data' => ['devices' => ['tablet']]]],
|
||||
]
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -57,19 +59,19 @@ it('forces 302 redirect status code for limited/expiring links', function () {
|
||||
|
||||
it('generates a deterministic privacy-safe GA4 client ID', function () {
|
||||
$job1 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
shortUrlId: 1,
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
|
||||
$job2 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
shortUrlId: 1,
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
|
||||
$job3 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
shortUrlId: 1,
|
||||
ipAddress: '8.8.8.8',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)'
|
||||
);
|
||||
@@ -228,12 +230,12 @@ it('anonymizes IP address correctly for IPv4 and IPv6', function () {
|
||||
$ipv4 = '192.168.1.123';
|
||||
$ipv6 = '2001:db8:85a3:8d3:1319:8a2e:370:7334';
|
||||
|
||||
expect(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::anonymizeIp($ipv4))->toBe('192.168.1.0')
|
||||
->and(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::anonymizeIp($ipv6))->toBe('2001:db8:85a3::');
|
||||
expect(ShortUrlTracker::anonymizeIp($ipv4))->toBe('192.168.1.0')
|
||||
->and(ShortUrlTracker::anonymizeIp($ipv6))->toBe('2001:db8:85a3::');
|
||||
});
|
||||
|
||||
it('anonymizes saved IP address when anonymize_ips is enabled but computes hash on raw IP', function () {
|
||||
$mgr = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager::class);
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
$mgr->set(['tracking_anonymize_ips' => true]);
|
||||
|
||||
$link = ShortUrl::create([
|
||||
@@ -242,11 +244,11 @@ it('anonymizes saved IP address when anonymize_ips is enabled but computes hash
|
||||
'track_ip_address' => true,
|
||||
]);
|
||||
|
||||
$request = \Illuminate\Http\Request::create('/anonkey', 'GET', [], [], [], [
|
||||
$request = Request::create('/anonkey', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
$tracker = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::class);
|
||||
$tracker = app(ShortUrlTracker::class);
|
||||
$visit = $tracker->record($link, $request);
|
||||
|
||||
expect($visit->ip_address)->toBe('1.2.3.0')
|
||||
|
||||
@@ -93,3 +93,46 @@ it('returns correct short URL link string based on custom domain association', f
|
||||
expect($shortUrl->getShortUrl())->toBe('https://links.acme.com/s/promo');
|
||||
expect($standardUrl->getShortUrl())->toBe('https://maindomain.com/s/stdkey');
|
||||
});
|
||||
|
||||
it('invalidates both old and new custom domain cache keys when domain name is updated', function () {
|
||||
$domain = ShortUrlCustomDomain::create([
|
||||
'domain' => 'old-domain.com',
|
||||
'is_verified' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
cache()->remember('filament-short-url:custom-domain:old-domain.com', 300, fn () => $domain);
|
||||
|
||||
expect(cache()->has('filament-short-url:custom-domain:old-domain.com'))->toBeTrue();
|
||||
|
||||
// Now update domain name
|
||||
$domain->update(['domain' => 'new-domain.com']);
|
||||
|
||||
// Both old and new domain cache keys must be cleared
|
||||
expect(cache()->has('filament-short-url:custom-domain:old-domain.com'))->toBeFalse();
|
||||
expect(cache()->has('filament-short-url:custom-domain:new-domain.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('invalidates short URL redirect caches when its custom domain is updated or toggled', function () {
|
||||
$domain = ShortUrlCustomDomain::create([
|
||||
'domain' => 'old-domain.com',
|
||||
'is_verified' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/target',
|
||||
'url_key' => 'promo',
|
||||
'custom_domain_id' => $domain->id,
|
||||
]);
|
||||
|
||||
cache()->remember('filament-short-url:promo:old-domain.com', 3600, fn () => $shortUrl);
|
||||
expect(cache()->has('filament-short-url:promo:old-domain.com'))->toBeTrue();
|
||||
|
||||
// Update the domain name
|
||||
$domain->update(['domain' => 'new-domain.com']);
|
||||
|
||||
// Redirect cache for the old domain should be invalidated
|
||||
expect(cache()->has('filament-short-url:promo:old-domain.com'))->toBeFalse();
|
||||
expect(cache()->has('filament-short-url:promo:new-domain.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
111
tests/Feature/ShortUrlFoldersAndTagsTest.php
Normal file
111
tests/Feature/ShortUrlFoldersAndTagsTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLiveFeedWidget;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('can assign a short URL to a folder and tags', function () {
|
||||
$folder = ShortUrlFolder::create([
|
||||
'name' => 'Marketing Campaigns',
|
||||
'slug' => 'marketing-campaigns',
|
||||
'color' => 'blue',
|
||||
]);
|
||||
|
||||
$tag1 = ShortUrlTag::create([
|
||||
'name' => 'Summer 2026',
|
||||
'slug' => 'summer-2026',
|
||||
'color' => 'red',
|
||||
]);
|
||||
|
||||
$tag2 = ShortUrlTag::create([
|
||||
'name' => 'Newsletter',
|
||||
'slug' => 'newsletter',
|
||||
'color' => 'green',
|
||||
]);
|
||||
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/promo',
|
||||
'url_key' => 'summer',
|
||||
'folder_id' => $folder->id,
|
||||
]);
|
||||
|
||||
$shortUrl->tags()->attach([$tag1->id, $tag2->id]);
|
||||
|
||||
// Assertions
|
||||
expect($shortUrl->folder->name)->toBe('Marketing Campaigns')
|
||||
->and($shortUrl->tags)->toHaveCount(2)
|
||||
->and($shortUrl->tags->pluck('name'))->toContain('Summer 2026', 'Newsletter');
|
||||
|
||||
// Assert reverse relations
|
||||
expect($folder->shortUrls)->toHaveCount(1)
|
||||
->and($tag1->shortUrls)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('can archive and restore a short URL', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/archive-test',
|
||||
'url_key' => 'archivekey',
|
||||
'is_archived' => false,
|
||||
]);
|
||||
|
||||
expect($shortUrl->is_archived)->toBeFalse();
|
||||
|
||||
$shortUrl->update(['is_archived' => true]);
|
||||
expect($shortUrl->fresh()->is_archived)->toBeTrue();
|
||||
|
||||
$shortUrl->update(['is_archived' => false]);
|
||||
expect($shortUrl->fresh()->is_archived)->toBeFalse();
|
||||
});
|
||||
|
||||
it('renders live activity feed widget with filtered data', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/live-test',
|
||||
'url_key' => 'livekey',
|
||||
]);
|
||||
|
||||
// Seed some visits
|
||||
$visitPl = ShortUrlVisit::create([
|
||||
'short_url_id' => $shortUrl->id,
|
||||
'country_code' => 'PL',
|
||||
'country' => 'Poland',
|
||||
'device_type' => 'mobile',
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'Android',
|
||||
'visited_at' => now(),
|
||||
'is_bot' => false,
|
||||
'is_proxy' => false,
|
||||
]);
|
||||
|
||||
$visitUs = ShortUrlVisit::create([
|
||||
'short_url_id' => $shortUrl->id,
|
||||
'country_code' => 'US',
|
||||
'country' => 'United States',
|
||||
'device_type' => 'desktop',
|
||||
'browser' => 'Safari',
|
||||
'operating_system' => 'OS X',
|
||||
'visited_at' => now()->subMinute(),
|
||||
'is_bot' => false,
|
||||
'is_proxy' => false,
|
||||
]);
|
||||
|
||||
// Test without filters
|
||||
Livewire::test(ShortUrlLiveFeedWidget::class, ['record' => $shortUrl])
|
||||
->assertViewHas('visits', function ($visits) {
|
||||
return count($visits) === 2;
|
||||
});
|
||||
|
||||
// Test with cross-filtering (PL only)
|
||||
Livewire::test(ShortUrlLiveFeedWidget::class, [
|
||||
'record' => $shortUrl,
|
||||
'filters' => ['country_code' => 'PL'],
|
||||
])
|
||||
->assertViewHas('visits', function ($visits) {
|
||||
return count($visits) === 1 && $visits->first()->country_code === 'PL';
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
@@ -834,3 +835,61 @@ it('evaluates platform and browser language filters in new rule engine', functio
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9',
|
||||
])->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('dispatches limit_reached webhook when link reaches its click limit', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
cache()->forget('filament-short-url:settings');
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'limit-reach-webhook',
|
||||
'max_visits' => 1,
|
||||
'track_visits' => true,
|
||||
'webhook_url' => 'https://webhook.site/limit',
|
||||
]);
|
||||
|
||||
// Force queue connection to sync for the job so it runs inline
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
// Perform visit (will trigger TrackShortUrlVisitJob which will increment total_visits to 1 >= max_visits)
|
||||
$this->get('/s/limit-reach-webhook');
|
||||
|
||||
Queue::assertPushed(SendWebhookJob::class, function ($job) {
|
||||
return $job->url === 'https://webhook.site/limit' && $job->event === 'limit_reached';
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches expired webhook when user attempts to visit an expired link', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
cache()->forget('filament-short-url:settings');
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'expired-webhook-test',
|
||||
'expires_at' => now()->subDay(),
|
||||
'track_visits' => true,
|
||||
'webhook_url' => 'https://webhook.site/expired',
|
||||
]);
|
||||
|
||||
// Perform visit to expired link
|
||||
$this->get('/s/expired-webhook-test');
|
||||
|
||||
Queue::assertPushed(SendWebhookJob::class, function ($job) {
|
||||
return $job->url === 'https://webhook.site/expired' && $job->event === 'expired';
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates redirect cache keys when the url_key is updated', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'old-key',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
cache()->remember('filament-short-url:old-key:default', 3600, fn () => $shortUrl);
|
||||
expect(cache()->has('filament-short-url:old-key:default'))->toBeTrue();
|
||||
|
||||
// Now update url_key
|
||||
$shortUrl->update(['url_key' => 'new-key']);
|
||||
|
||||
// Cache for both keys should be cleared
|
||||
expect(cache()->has('filament-short-url:old-key:default'))->toBeFalse();
|
||||
expect(cache()->has('filament-short-url:new-key:default'))->toBeFalse();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user