feat: global overview widget, optimized cache, CSS asset docs
- Add ShortUrlGlobalOverview StatsOverviewWidget with split cache strategy: link counts cached forever (invalidated on create/delete), click stats with short TTL (geo_ip.stats_cache_ttl config) - Fix visited_at column in ShortUrlVisit queries (was created_at) - Disable Livewire polling (pollingInterval = null) - Add cache invalidation for global overview in ShortUrl::booted() - Rewrite README asset section: user-facing install instructions clarified, post-autoload-dump tip added, developer recompile notes separated
This commit is contained in:
56
README.md
56
README.md
@@ -85,11 +85,47 @@ Copies the dashboard components, charts, and QR designer templates to `resources
|
||||
php artisan vendor:publish --tag=filament-short-url-views
|
||||
```
|
||||
|
||||
### 4. Publish All Assets
|
||||
### 4. Publish CSS Assets
|
||||
|
||||
The plugin ships with a pre-compiled stylesheet. Copy it to your application's public directory so the browser can load it:
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --provider="Bjanczak\FilamentShortUrl\FilamentShortUrlServiceProvider"
|
||||
php artisan filament:assets
|
||||
```
|
||||
|
||||
That's all. The plugin's CSS will be served from `public/css/janczakb/filament-short-url/filament-short-url.css` and Filament registers it automatically.
|
||||
|
||||
> **You do not need to run Tailwind, Vite, or npm for the plugin styles.** The compiled file is included in the package.
|
||||
|
||||
**Tip — automate on every `composer install` / `composer update`:**
|
||||
|
||||
Add `filament:assets` to the `post-autoload-dump` scripts in your application's `composer.json` so the assets are always up to date without manual steps:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi",
|
||||
"@php artisan filament:assets"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> #### 🛠 For Plugin Developers Only
|
||||
>
|
||||
> If you are modifying the plugin source and need to recompile its stylesheet after changing Blade/PHP files:
|
||||
>
|
||||
> ```bash
|
||||
> # 1. Recompile the plugin CSS with Tailwind v4
|
||||
> npx @tailwindcss/cli -i ./packages/filament-short-url/resources/css/plugin.css \
|
||||
> -o ./packages/filament-short-url/resources/dist/filament-short-url.css --minify
|
||||
>
|
||||
> # 2. Re-publish the compiled asset to public/
|
||||
> php artisan filament:assets
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
@@ -342,7 +378,14 @@ You can also pre-configure all parameters via your `.env` file:
|
||||
| `short-url:sync-counters` | Flushes buffered visit counts from cache to the database. Schedule every minute when counter buffering is enabled. |
|
||||
| `short-url:aggregate-and-prune` | Aggregates previous days' raw visits into `short_url_daily_stats` and prunes raw records older than the configured retention period. Schedule daily (e.g. at `02:00`). |
|
||||
|
||||
### Recommended Scheduler Configuration
|
||||
### Automatic Scheduler Registration (v1.3.0)
|
||||
|
||||
As of version `1.3.0`, **you no longer need to manually copy scheduled commands into your host application code!** The plugin automatically registers the required tasks inside its ServiceProvider booted phase, dynamically respecting your Settings GUI toggles:
|
||||
|
||||
- **`short-url:aggregate-and-prune`** is scheduled **daily at 02:00** (runs automatically only if **Enable Daily Aggregation** is ON).
|
||||
- **`short-url:sync-counters`** is scheduled **every minute** (runs automatically only if **Buffer Visit Counts in Cache** is ON).
|
||||
|
||||
If you prefer to configure the schedule manually, you can still define them in `routes/console.php` (ensure the corresponding toggles are turned OFF in your Settings panel to avoid redundant executions):
|
||||
|
||||
```php
|
||||
// routes/console.php
|
||||
@@ -457,6 +500,13 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.3.0 (Latest)
|
||||
- **Automatic Scheduler Registration** — Zero-configuration task registration within the ServiceProvider booted phase (dynamically honors Settings toggles).
|
||||
- **Interactive Settings Validators** — Adds real-time "Test connection" verify action for GA4 Measurement Protocol and "Verify file" action for MaxMind database paths.
|
||||
- **Robust Table Row Copy Action** — High-reliability, conflict-free click-to-copy in table rows with built-in fallback helper for non-HTTPS (secure context) browser environments.
|
||||
- **Filament v5 Notification API** — Seamless integration of the new `FilamentNotification` class API inside client-side JS.
|
||||
- **Asset Compilation Guide** — Explains Tailwind CLI CSS compilation and Filament asset publishing workflows for package extensions.
|
||||
|
||||
### v1.2.0
|
||||
- **Password-protected links** — Session-based unlock flow with a styled prompt page.
|
||||
- **Redirect warning pages** — Interstitial security screen before external redirects.
|
||||
|
||||
@@ -46,7 +46,7 @@ return [
|
||||
'driver' => env('SHORT_URL_GEO_IP_DRIVER', 'headers'), // 'headers', 'maxmind', 'ip-api'
|
||||
'timeout' => 3, // seconds to wait for geo-ip response
|
||||
'maxmind' => [
|
||||
'database_path' => env('SHORT_URL_MAXMIND_DB', database_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'database_path' => env('SHORT_URL_MAXMIND_DB', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
],
|
||||
'stats_cache_ttl' => env('SHORT_URL_STATS_CACHE_TTL', 300), // 5 minutes caching for stats page calculations
|
||||
],
|
||||
|
||||
17
resources/css/plugin.css
Normal file
17
resources/css/plugin.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../../src/**/*.php";
|
||||
@source "../../resources/views/**/*.blade.php";
|
||||
|
||||
.short-url-card {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.short-url-card .fi-ta-actions {
|
||||
position: absolute !important;
|
||||
top: 1.25rem !important;
|
||||
right: 1.25rem !important;
|
||||
z-index: 10 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
2
resources/dist/filament-short-url.css
vendored
Normal file
2
resources/dist/filament-short-url.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Short URLs',
|
||||
'navigation_label' => 'Short Links',
|
||||
'navigation_group' => 'Tools',
|
||||
'resource_title' => 'Short URL',
|
||||
|
||||
@@ -106,6 +106,13 @@ return [
|
||||
'action_stats' => 'Statistics',
|
||||
'action_copy' => 'Copy URL',
|
||||
'action_qr' => 'QR Code',
|
||||
'action_edit' => 'Edit URL',
|
||||
'action_delete' => 'Delete URL',
|
||||
'action_share' => 'Share Link',
|
||||
'share_title' => 'Share Link',
|
||||
'share_description' => 'Share this short link via:',
|
||||
'share_copy' => 'Copy',
|
||||
'share_copied' => 'Short link copied to clipboard!',
|
||||
|
||||
// Stats Page
|
||||
'stats_title' => 'Statistics',
|
||||
@@ -175,11 +182,20 @@ return [
|
||||
'settings_geoip_timeout_helper' => 'Maximum seconds to wait for an external Geo-IP API response before giving up.',
|
||||
'settings_maxmind_path' => 'MaxMind Database Path',
|
||||
'settings_maxmind_path_helper' => 'Absolute path to your GeoLite2-Country.mmdb or GeoIP2-Country.mmdb file. Required when driver is set to MaxMind.',
|
||||
'settings_maxmind_verify' => 'Verify file',
|
||||
'settings_maxmind_verify_ok' => '✅ File found & readable',
|
||||
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
|
||||
'settings_maxmind_verify_empty' => 'Please enter a path first.',
|
||||
|
||||
'settings_ga4_api_secret' => 'Measurement Protocol API Secret',
|
||||
'settings_ga4_api_secret_helper' => 'Generate this in GA4 → Admin → Data Streams → your stream → Measurement Protocol API secrets.',
|
||||
'settings_ga4_firebase_app_id' => 'Firebase App ID (optional)',
|
||||
'settings_ga4_firebase_app_id_helper' => 'Required only if tracking a Firebase / app stream. Leave empty for standard web GA4 streams.',
|
||||
'settings_ga4_verify' => 'Test connection',
|
||||
'settings_ga4_verify_ok' => '✅ API Secret is valid — GA4 connection successful',
|
||||
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
|
||||
'settings_ga4_verify_empty' => 'Please enter an API Secret first.',
|
||||
'settings_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
|
||||
|
||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
||||
@@ -254,10 +270,16 @@ return [
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Performance & Security',
|
||||
'settings_section_aggregation' => 'High-Traffic Log Management',
|
||||
'settings_retention_days' => 'Prune Raw Visit Logs After (Days)',
|
||||
'settings_retention_days_helper' => 'Raw visit records will be aggregated daily. Older raw logs will be purged after this duration. Use 0 to disable pruning.',
|
||||
'settings_aggregation_enabled' => 'Enable Daily Aggregation',
|
||||
'settings_aggregation_enabled_helper' => 'Summarize previous days\' raw visits into daily count tables to keep the database fast.',
|
||||
'settings_retention_days' => 'Raw Log Retention Period',
|
||||
'settings_retention_days_helper' => 'Select the duration for which you want to keep detailed raw click logs before they are deleted.',
|
||||
'settings_aggregation_enabled' => 'Enable Automatic Daily Pruning & Aggregation',
|
||||
'settings_aggregation_enabled_helper' => 'When enabled, the plugin automatically registers a daily task in the scheduler (at 02:00) to aggregate visits into daily statistics and delete raw logs older than the chosen retention period.',
|
||||
'retention_30_days' => '30 Days',
|
||||
'retention_60_days' => '60 Days',
|
||||
'retention_90_days' => '90 Days',
|
||||
'retention_180_days' => '180 Days (Half Year)',
|
||||
'retention_365_days' => '365 Days (1 Year)',
|
||||
'retention_730_days' => '2 Years',
|
||||
'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
|
||||
'settings_rate_limiting_enabled' => 'Enable Limit Protection',
|
||||
'settings_rate_limiting_enabled_helper' => 'Limit the rate of redirects per client IP address.',
|
||||
|
||||
@@ -106,6 +106,13 @@ return [
|
||||
'action_stats' => 'Statystyki',
|
||||
'action_copy' => 'Kopiuj link',
|
||||
'action_qr' => 'Kod QR',
|
||||
'action_edit' => 'Edytuj URL',
|
||||
'action_delete' => 'Usuń URL',
|
||||
'action_share' => 'Udostępnij link',
|
||||
'share_title' => 'Udostępnij Link',
|
||||
'share_description' => 'Udostępnij ten krótki link przez:',
|
||||
'share_copy' => 'Skopiuj',
|
||||
'share_copied' => 'Krótki link skopiowany do schowka!',
|
||||
|
||||
// Stats Page
|
||||
'stats_title' => 'Statystyki',
|
||||
@@ -176,6 +183,10 @@ return [
|
||||
'settings_geoip_timeout_helper' => 'Maksymalna liczba sekund oczekiwania na odpowiedź zewnętrznego API Geo-IP.',
|
||||
'settings_maxmind_path' => 'Ścieżka bazy MaxMind',
|
||||
'settings_maxmind_path_helper' => 'Bezwzględna ścieżka do pliku GeoLite2-Country.mmdb lub GeoIP2-Country.mmdb. Wymagana gdy sterownik to MaxMind.',
|
||||
'settings_maxmind_verify' => 'Weryfikuj plik',
|
||||
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
|
||||
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
|
||||
'settings_maxmind_verify_empty' => 'Najpierw podaj ścieżkę do pliku.',
|
||||
|
||||
'settings_ga4_api_secret' => 'Klucz API Measurement Protocol',
|
||||
'settings_ga4_api_secret_helper' => 'Wygeneruj w GA4 → Admin → Strumienie danych → twój strumień → Klucze API Measurement Protocol.',
|
||||
@@ -255,10 +266,16 @@ return [
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
|
||||
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
|
||||
'settings_retention_days' => 'Usuwaj surowe logi po (dniach)',
|
||||
'settings_retention_days_helper' => 'Surowe logi wizyt będą codziennie agregowane. Starsze surowe wpisy zostaną usunięte po tym czasie. Wpisz 0, aby wyłączyć usuwanie.',
|
||||
'settings_aggregation_enabled' => 'Włącz codzienną agregację',
|
||||
'settings_aggregation_enabled_helper' => 'Agreguj surowe wizyty z poprzednich dni w dzienną tabelę statystyk, aby baza danych działała szybko.',
|
||||
'settings_retention_days' => 'Czas retencji surowych logów',
|
||||
'settings_retention_days_helper' => 'Wybierz, przez jaki okres czasu chcesz przechowywać szczegółowe logi pojedynczych wizyt przed ich usunięciem.',
|
||||
'settings_aggregation_enabled' => 'Włącz automatyczną agregację i czyszczenie logów',
|
||||
'settings_aggregation_enabled_helper' => 'Gdy włączone, wtyczka automatycznie rejestruje zadanie w harmonogramie (codziennie o 02:00), aby zsumować wizyty do statystyk dziennych i usunąć surowe logi starsze niż wybrany okres retencji.',
|
||||
'retention_30_days' => '30 dni',
|
||||
'retention_60_days' => '60 dni',
|
||||
'retention_90_days' => '90 dni',
|
||||
'retention_180_days' => '180 dni (pół roku)',
|
||||
'retention_365_days' => '365 dni (rok)',
|
||||
'retention_730_days' => '2 lata',
|
||||
'settings_section_rate_limiting' => 'Ograniczanie częstotliwości żądań / Ochrona przed botami',
|
||||
'settings_rate_limiting_enabled' => 'Włącz ochronę limitów',
|
||||
'settings_rate_limiting_enabled_helper' => 'Ograniczaj liczbę przekierowań na adres IP klienta.',
|
||||
|
||||
@@ -4,50 +4,98 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.jsx'])
|
||||
<style>
|
||||
body {
|
||||
background: radial-gradient(circle at 10% 20%, rgb(239, 246, 255) 0%, rgb(219, 234, 254) 100%);
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark body {
|
||||
background: radial-gradient(circle at 10% 20%, rgb(17, 24, 39) 0%, rgb(15, 23, 42) 100%);
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
</head>
|
||||
<body class="flex h-full items-center justify-center p-4 antialiased dark:text-white">
|
||||
<div class="w-full max-w-md rounded-2xl border border-white/20 bg-white/60 p-8 shadow-xl backdrop-blur-xl dark:border-gray-800/30 dark:bg-gray-900/60">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-amber-50 text-amber-500 dark:bg-amber-950/30 dark:text-amber-400">
|
||||
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-4 text-xl font-bold text-gray-900 dark:text-white">
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Sign-in Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="mt-6 space-y-4">
|
||||
|
||||
<form method="POST" class="w-full flex flex-col gap-4">
|
||||
@csrf
|
||||
<div>
|
||||
<input id="password" name="password" type="password" required placeholder="{{ __('filament-short-url::default.password_placeholder') ?? 'Enter password' }}"
|
||||
class="w-full rounded-xl border border-gray-200 bg-white/80 px-4 py-3 text-sm text-gray-900 outline-none transition-all focus:border-amber-500 focus:ring-2 focus:ring-amber-500/20 dark:border-gray-800 dark:bg-gray-950/80 dark:text-white dark:focus:border-amber-500" />
|
||||
|
||||
|
||||
{{-- Password input styled exactly like Custom Login --}}
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="relative flex flex-col justify-center px-3 py-2 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:ring-2 focus-within:ring-neutral-900 dark:focus-within:ring-white transition duration-200">
|
||||
<label for="password" class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
{{ __('filament-short-url::default.password_placeholder') ?? 'Password' }}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autofocus
|
||||
class="w-full bg-transparent text-sm text-neutral-900 dark:text-white placeholder-neutral-300 dark:placeholder-neutral-700 focus:outline-none py-0.5 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
@if ($errors->has('password'))
|
||||
<p class="mt-2 text-xs font-medium text-rose-500">
|
||||
{{ $errors->first('password') }}
|
||||
</p>
|
||||
<span class="text-xs text-red-500 px-1 mt-0.5">{{ $errors->first('password') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="flex w-full items-center justify-center rounded-xl bg-amber-500 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-amber-500/20 transition-all hover:bg-amber-600 hover:shadow-amber-500/30 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2">
|
||||
{{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }}
|
||||
{{-- Submit button --}}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2"
|
||||
>
|
||||
<span>{{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,49 +4,110 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.warning_title') ?? 'Redirect Warning' }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.jsx'])
|
||||
<style>
|
||||
body {
|
||||
background: radial-gradient(circle at 10% 20%, rgb(254, 242, 242) 0%, rgb(254, 226, 226) 100%);
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark body {
|
||||
background: radial-gradient(circle at 10% 20%, rgb(17, 24, 39) 0%, rgb(15, 23, 42) 100%);
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
</head>
|
||||
<body class="flex h-full items-center justify-center p-4 antialiased dark:text-white">
|
||||
<div class="w-full max-w-lg rounded-2xl border border-white/20 bg-white/60 p-8 shadow-xl backdrop-blur-xl dark:border-gray-800/30 dark:bg-gray-900/60">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-rose-50 text-rose-500 dark:bg-rose-950/30 dark:text-rose-400">
|
||||
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-4 text-xl font-bold text-gray-900 dark:text-white">
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Warning Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.warning_title') ?? 'Security Redirect Warning' }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.warning_description') ?? 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
{{-- Destination URL Container styled exactly like Custom Login input box --}}
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="relative flex items-center justify-between px-3 py-3 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition duration-200">
|
||||
<div class="flex-grow flex flex-col justify-center min-w-0 pr-4">
|
||||
<span class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
Destination URL
|
||||
</span>
|
||||
<p id="dest-url-text" class="break-all text-xs font-mono font-semibold text-rose-600 dark:text-rose-400 mt-1 select-all">
|
||||
{{ $destinationUrl }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Copy Button -->
|
||||
<button onclick="
|
||||
const text = document.getElementById('dest-url-text').innerText;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = this;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = `<svg class='h-4 w-4 text-emerald-500' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='3.5'><path stroke-linecap='round' stroke-linejoin='round' d='M5 13l4 4L19 7' /></svg>`;
|
||||
setTimeout(() => { btn.innerHTML = origHtml; }, 2000);
|
||||
" class="flex-shrink-0 p-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors focus:outline-none" title="Copy URL">
|
||||
<svg class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-xl border border-rose-100 bg-rose-50/50 p-4 dark:border-rose-950/20 dark:bg-rose-950/10">
|
||||
<p class="break-all font-mono text-sm font-semibold text-rose-700 dark:text-rose-400">
|
||||
{{ $destinationUrl }}
|
||||
</p>
|
||||
{{-- Action Buttons --}}
|
||||
<div class="flex flex-col gap-3 w-full mt-2">
|
||||
<!-- Continue Button (Primary Accent) -->
|
||||
<a href="{{ request()->fullUrlWithQuery(['confirmed' => 1]) }}"
|
||||
class="w-full py-3.5 rounded-xl bg-rose-600 hover:bg-rose-500 dark:bg-rose-500 dark:hover:bg-rose-400 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Go Back Button (Secondary) -->
|
||||
<button onclick="window.history.back()"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm">
|
||||
{{ __('filament-short-url::default.warning_btn_back') ?? 'Go Back' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<a href="{{ request()->fullUrlWithQuery(['confirmed' => 1]) }}"
|
||||
class="flex flex-1 items-center justify-center rounded-xl bg-rose-500 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-500/20 transition-all hover:bg-rose-600 hover:shadow-rose-500/30 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2">
|
||||
{{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }}
|
||||
</a>
|
||||
|
||||
<button onclick="window.history.back()"
|
||||
class="flex flex-1 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-700 shadow-sm transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-300 dark:hover:bg-gray-900">
|
||||
{{ __('filament-short-url::default.warning_btn_back') ?? 'Go Back' }}
|
||||
</button>
|
||||
</div>
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
@@ -40,6 +41,8 @@ class ListShortUrls extends ManageRecords
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
ShortUrlGlobalOverview::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class ShortUrlSettingsPage extends Page implements HasForms
|
||||
{
|
||||
@@ -187,7 +189,39 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->label(__('filament-short-url::default.settings_maxmind_path'))
|
||||
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
|
||||
->columnSpanFull()
|
||||
->placeholder(database_path('geoip/GeoLite2-Country.mmdb'))
|
||||
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
|
||||
->suffixAction(
|
||||
Action::make('verifyMaxmindPath')
|
||||
->label(__('filament-short-url::default.settings_maxmind_verify'))
|
||||
->icon('heroicon-o-server')
|
||||
->action(function (?string $state): void {
|
||||
$path = trim($state ?? '');
|
||||
|
||||
if (empty($path)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($path) && is_readable($path) && str_ends_with($path, '.mmdb')) {
|
||||
$sizeKb = round(filesize($path) / 1024);
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_ok'))
|
||||
->body("{$path} ({$sizeKb} KB)")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_fail'))
|
||||
->body($path)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
)
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
]),
|
||||
]),
|
||||
@@ -204,15 +238,76 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->live()
|
||||
->placeholder('••••••••••••••••••••'),
|
||||
|
||||
// Firebase App ID only when API Secret is set
|
||||
// Firebase App ID — always visible
|
||||
TextInput::make('ga4_firebase_app_id')
|
||||
->label(__('filament-short-url::default.settings_ga4_firebase_app_id'))
|
||||
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
|
||||
->placeholder('1:1234567890:android:abcdef123456')
|
||||
->visible(fn (Get $get): bool => filled($get('ga4_api_secret'))),
|
||||
->placeholder('1:1234567890:android:abcdef123456'),
|
||||
|
||||
// GA4 connection verify button
|
||||
Actions::make([
|
||||
Action::make('verifyGa4ApiSecret')
|
||||
->label(__('filament-short-url::default.settings_ga4_verify'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$secret = trim($get('ga4_api_secret') ?? '');
|
||||
|
||||
if (empty($secret)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// GA4 debug endpoint — a valid api_secret returns event-level
|
||||
// validation messages; an invalid one returns auth errors.
|
||||
$response = Http::timeout(5)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post(
|
||||
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret='.urlencode($secret),
|
||||
[
|
||||
'client_id' => 'short-url-plugin-verify',
|
||||
'events' => [
|
||||
['name' => 'page_view', 'params' => []],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$body = $response->json();
|
||||
$messages = $body['validationMessages'] ?? [];
|
||||
|
||||
// A valid secret returns event-level messages, not auth errors
|
||||
$hasAuthError = collect($messages)->contains(fn ($m) => str_contains(
|
||||
strtolower($m['description'] ?? ''),
|
||||
'api_secret'
|
||||
));
|
||||
|
||||
if ($hasAuthError || $response->status() === 401) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
|
||||
->danger()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -230,11 +325,17 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
TextInput::make('pruning_retention_days')
|
||||
Select::make('pruning_retention_days')
|
||||
->label(__('filament-short-url::default.settings_retention_days'))
|
||||
->helperText(__('filament-short-url::default.settings_retention_days_helper'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->options([
|
||||
30 => __('filament-short-url::default.retention_30_days'),
|
||||
60 => __('filament-short-url::default.retention_60_days'),
|
||||
90 => __('filament-short-url::default.retention_90_days'),
|
||||
180 => __('filament-short-url::default.retention_180_days'),
|
||||
365 => __('filament-short-url::default.retention_365_days'),
|
||||
730 => __('filament-short-url::default.retention_730_days'),
|
||||
])
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('pruning_enabled')),
|
||||
]),
|
||||
|
||||
@@ -7,15 +7,16 @@ use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Forms;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ShortUrlsTable
|
||||
{
|
||||
@@ -23,60 +24,147 @@ class ShortUrlsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('url_key')
|
||||
->label(__('filament-short-url::default.col_short_url'))
|
||||
->getStateUsing(fn (ShortUrl $record): string => $record->getShortUrl())
|
||||
->copyable()
|
||||
->copyMessage(__('filament-short-url::default.qr_copied'))
|
||||
->fontFamily('mono')
|
||||
->weight(FontWeight::SemiBold)
|
||||
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
|
||||
Stack::make([
|
||||
// ROW 1: Favicon + Short URL with copy capability on click of text or icon (vertically centered!)
|
||||
TextColumn::make('url_key')
|
||||
->label(__('filament-short-url::default.col_short_url'))
|
||||
->getStateUsing(static function (ShortUrl $record): HtmlString {
|
||||
$shortUrl = e($record->getShortUrl());
|
||||
$copiedMsg = e(__('filament-short-url::default.qr_copied'));
|
||||
$tooltipCopy = e(__('filament-short-url::default.action_copy'));
|
||||
$destHost = e(parse_url($record->destination_url, PHP_URL_HOST));
|
||||
|
||||
TextColumn::make('destination_url')
|
||||
->label(__('filament-short-url::default.col_destination_url'))
|
||||
->limit(45)
|
||||
->tooltip(fn (ShortUrl $record): string => $record->destination_url)
|
||||
->url(fn (ShortUrl $record): string => $record->destination_url, shouldOpenInNewTab: true)
|
||||
->searchable(),
|
||||
return new HtmlString(<<<HTML
|
||||
<div onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const text = '{$shortUrl}';
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textCopyArea = document.createElement('textarea');
|
||||
textCopyArea.value = text;
|
||||
textCopyArea.style.position = 'fixed';
|
||||
textCopyArea.style.left = '-999999px';
|
||||
textCopyArea.style.top = '-999999px';
|
||||
document.body.appendChild(textCopyArea);
|
||||
textCopyArea.focus();
|
||||
textCopyArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (err) {}
|
||||
textCopyArea.remove();
|
||||
}
|
||||
if (typeof FilamentNotification !== 'undefined') {
|
||||
new FilamentNotification()
|
||||
.title('{$copiedMsg}')
|
||||
.success()
|
||||
.send();
|
||||
} else if (window.Alpine) {
|
||||
window.Alpine.store('filament-notifications')?.send({
|
||||
status: 'success',
|
||||
title: '{$copiedMsg}'
|
||||
});
|
||||
}
|
||||
" class="flex items-center gap-3 cursor-pointer w-fit">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<span class="text-[#2a5bd7] text-[16px] font-bold leading-6 break-all line-clamp-1">
|
||||
{$shortUrl}
|
||||
</span>
|
||||
<span title="{$tooltipCopy}" class="w-8 h-8 rounded-full flex items-center justify-center bg-[#f4f4f5] hover:bg-[#e4e4e7] dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0 focus:outline-none">
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
HTML
|
||||
);
|
||||
})
|
||||
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
|
||||
|
||||
TextColumn::make('total_visits')
|
||||
->label(__('filament-short-url::default.col_total_visits'))
|
||||
->numeric()
|
||||
->badge()
|
||||
->color('gray')
|
||||
->sortable(),
|
||||
// ROW 2: Sub-arrow + Destination URL
|
||||
TextColumn::make('destination_url')
|
||||
->label(__('filament-short-url::default.col_destination_url'))
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-2">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
||||
style="transform: scaleY(-1);"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
|
||||
</svg>
|
||||
<a href="'.e($record->destination_url).'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate max-w-[50ch] hover:underline text-[#273144] text-[15px] leading-[16px] font-medium"
|
||||
title="'.e($record->destination_url).'">
|
||||
'.e($record->destination_url).'
|
||||
</a>
|
||||
</div>
|
||||
'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('unique_visits')
|
||||
->label(__('filament-short-url::default.stats_card_unique'))
|
||||
->numeric()
|
||||
->badge()
|
||||
->color('info')
|
||||
->sortable(),
|
||||
// ROW 3: Bottom Metadata (Total clicks / Unique clicks / Date added / Expiry / Redirect code)
|
||||
TextColumn::make('metadata_badges')
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<!-- Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
'.$record->total_visits.' clicks
|
||||
</span>
|
||||
|
||||
ToggleColumn::make('is_enabled')
|
||||
->label(__('filament-short-url::default.col_status'))
|
||||
->sortable(),
|
||||
<!-- Unique Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
'.$record->unique_visits.' unique
|
||||
</span>
|
||||
|
||||
IconColumn::make('track_visits')
|
||||
->label(__('filament-short-url::default.track_visits'))
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-signal')
|
||||
->falseIcon('heroicon-o-signal-slash')
|
||||
->trueColor('success')
|
||||
->falseColor('gray'),
|
||||
<!-- Date Added Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
'.$record->created_at->format('M d, Y').'
|
||||
</span>
|
||||
|
||||
TextColumn::make('expires_at')
|
||||
->label(__('filament-short-url::default.col_expires_at'))
|
||||
->dateTime('d M Y')
|
||||
->placeholder('Never')
|
||||
->color(fn (ShortUrl $record): string => $record->isExpired() ? 'danger' : 'gray')
|
||||
->sortable(),
|
||||
<!-- Expiry / Single Use Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
'.($record->expires_at ? '
|
||||
<svg class="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Expires: '.$record->expires_at->format('M d, Y').'
|
||||
' : '
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
No expiry
|
||||
').'
|
||||
</span>
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('filament-short-url::default.col_created_at'))
|
||||
->since()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
<!-- Redirect Type Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
'.$record->redirect_status_code.' redirect
|
||||
</span>
|
||||
</div>
|
||||
')),
|
||||
]),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_enabled')
|
||||
@@ -96,18 +184,139 @@ class ShortUrlsTable
|
||||
'1' => 'Single-use',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
->recordClasses(fn (): string => 'short-url-card')
|
||||
->actions([
|
||||
Action::make('share')
|
||||
->label(__('filament-short-url::default.action_share'))
|
||||
->icon('heroicon-o-share')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_share'))
|
||||
->modalHeading(__('filament-short-url::default.share_title'))
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form([
|
||||
Forms\Components\Placeholder::make('platforms')
|
||||
->label(__('filament-short-url::default.share_description'))
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-6 overflow-x-auto pb-4 pt-1 scroll-smooth" style="scrollbar-width: thin; -ms-overflow-style: none;">
|
||||
<!-- Messenger -->
|
||||
<a href="fb-messenger://share/?link='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-tr from-[#006aff] via-[#00b2ff] to-[#00d6ff] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.14 2 11.25c0 2.9 1.45 5.48 3.73 7.08v3.67c0 .24.23.4.43.27l4.07-2.3c.57.16 1.17.25 1.77.25 5.52 0 10-4.14 10-9.25S17.52 2 12 2zm1.09 11.95l-2.43-2.6-4.73 2.6 5.19-5.52 2.47 2.63 4.7-2.63-5.2 5.52z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Messenger</span>
|
||||
</a>
|
||||
<!-- Facebook -->
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#1877f2] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-550 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Facebook</span>
|
||||
</a>
|
||||
<!-- WhatsApp -->
|
||||
<a href="https://api.whatsapp.com/send?text='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#25d366] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">WhatsApp</span>
|
||||
</a>
|
||||
<!-- Twitter/X -->
|
||||
<a href="https://twitter.com/intent/tweet?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0f1419] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Twitter (X)</span>
|
||||
</a>
|
||||
<!-- LinkedIn -->
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0077b5] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">LinkedIn</span>
|
||||
</a>
|
||||
<!-- Email -->
|
||||
<a href="mailto:?body='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#6b7280] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Email</span>
|
||||
</a>
|
||||
</div>
|
||||
')),
|
||||
|
||||
Forms\Components\Placeholder::make('copy_field')
|
||||
->label('')
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-2 relative mt-2">
|
||||
<input type="text"
|
||||
readonly
|
||||
value="'.e($record->getShortUrl()).'"
|
||||
id="share_link_input_'.$record->id.'"
|
||||
class="flex-1 min-w-0 block w-full px-3.5 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-gray-100 text-sm focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 focus:outline-none">
|
||||
|
||||
<button onclick="
|
||||
const input = document.getElementById(\'share_link_input_'.$record->id.'\');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value);
|
||||
if (typeof FilamentNotification !== \'undefined\') {
|
||||
new FilamentNotification()
|
||||
.title(\''.e(__('filament-short-url::default.share_copied')).'\')
|
||||
.success()
|
||||
.send();
|
||||
} else if (typeof Alpine !== \'undefined\') {
|
||||
Alpine.store(\'filament-notifications\')?.send({
|
||||
status: \'success\',
|
||||
title: \''.e(__('filament-short-url::default.share_copied')).'\'
|
||||
});
|
||||
}
|
||||
"
|
||||
class="flex-shrink-0 inline-flex items-center justify-center gap-1.5 px-4 py-2.5 bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-white text-white dark:text-gray-950 font-semibold text-sm rounded-lg shadow-sm hover:shadow transition duration-200">
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>'.e(__('filament-short-url::default.share_copy')).'</span>
|
||||
</button>
|
||||
</div>
|
||||
')),
|
||||
]),
|
||||
|
||||
Action::make('stats')
|
||||
->label(__('filament-short-url::default.action_stats'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_stats'))
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->size('sm')
|
||||
->color('gray'),
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_edit')),
|
||||
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
])
|
||||
->contentGrid([
|
||||
'md' => 1,
|
||||
'xl' => 1,
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShortUrlGlobalOverview extends BaseWidget
|
||||
{
|
||||
/** Disable automatic Livewire polling — cache is invalidated via model events instead. */
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/** Cache key for link counts (forever, busted on create/delete). */
|
||||
public const LINKS_CACHE_KEY = 'filament-short-url:global-links';
|
||||
|
||||
/** Cache key for click trends (short TTL, time-based data). */
|
||||
public const CLICKS_CACHE_KEY = 'filament-short-url:global-clicks';
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$linkData = $this->getLinkStats();
|
||||
$clickData = $this->getClickStats();
|
||||
|
||||
$clicksTrend = $clickData['prev7Clicks'] > 0
|
||||
? round((($clickData['last7Clicks'] - $clickData['prev7Clicks']) / $clickData['prev7Clicks']) * 100, 1)
|
||||
: ($clickData['last7Clicks'] > 0 ? 100.0 : 0.0);
|
||||
|
||||
$uniquesTrend = $clickData['prev7Uniques'] > 0
|
||||
? round((($clickData['last7Uniques'] - $clickData['prev7Uniques']) / $clickData['prev7Uniques']) * 100, 1)
|
||||
: ($clickData['last7Uniques'] > 0 ? 100.0 : 0.0);
|
||||
|
||||
return [
|
||||
Stat::make(__('filament-short-url::default.navigation_label'), number_format($linkData['totalLinks']))
|
||||
->description($linkData['activeLinks'].' active · '.$linkData['activeRatio'].'%')
|
||||
->descriptionIcon('heroicon-m-link')
|
||||
->icon('heroicon-o-link')
|
||||
->color('gray'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($clickData['totalVisits']))
|
||||
->description(abs($clicksTrend).'% '.($clicksTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($clicksTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->icon('heroicon-o-cursor-arrow-rays')
|
||||
->color($clicksTrend >= 0 ? 'success' : 'danger'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($clickData['uniqueVisits']))
|
||||
->description(abs($uniquesTrend).'% '.($uniquesTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($uniquesTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->icon('heroicon-o-user-group')
|
||||
->color($uniquesTrend >= 0 ? 'success' : 'danger'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Link counts cached forever — invalidated only when a ShortUrl is created or deleted.
|
||||
*
|
||||
* @return array{totalLinks: int, activeLinks: int, activeRatio: int}
|
||||
*/
|
||||
protected function getLinkStats(): array
|
||||
{
|
||||
return Cache::rememberForever(self::LINKS_CACHE_KEY, function () {
|
||||
$agg = ShortUrl::query()
|
||||
->select([
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw('SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as active'),
|
||||
])
|
||||
->first();
|
||||
|
||||
$total = (int) ($agg->total ?? 0);
|
||||
$active = (int) ($agg->active ?? 0);
|
||||
|
||||
return [
|
||||
'totalLinks' => $total,
|
||||
'activeLinks' => $active,
|
||||
'activeRatio' => $total > 0 ? round(($active / $total) * 100) : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click/visit stats cached with a short TTL (time-based data changes frequently).
|
||||
*
|
||||
* @return array{totalVisits: int, uniqueVisits: int, last7Clicks: int, prev7Clicks: int, last7Uniques: int, prev7Uniques: int}
|
||||
*/
|
||||
protected function getClickStats(): array
|
||||
{
|
||||
$ttl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
|
||||
return Cache::remember(self::CLICKS_CACHE_KEY, $ttl, function () {
|
||||
$agg = ShortUrl::query()
|
||||
->select([
|
||||
DB::raw('SUM(total_visits) as total_visits'),
|
||||
DB::raw('SUM(unique_visits) as unique_visits'),
|
||||
])
|
||||
->first();
|
||||
|
||||
$last7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))->count();
|
||||
$prev7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(14))
|
||||
->where('visited_at', '<', now()->subDays(7))
|
||||
->count();
|
||||
|
||||
$last7Uniques = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))
|
||||
->distinct('ip_address')
|
||||
->count('ip_address');
|
||||
|
||||
$prev7Uniques = ShortUrlVisit::whereBetween('visited_at', [now()->subDays(14), now()->subDays(7)])
|
||||
->distinct('ip_address')
|
||||
->count('ip_address');
|
||||
|
||||
return [
|
||||
'totalVisits' => (int) ($agg->total_visits ?? 0),
|
||||
'uniqueVisits' => (int) ($agg->unique_visits ?? 0),
|
||||
'last7Clicks' => $last7Clicks,
|
||||
'prev7Clicks' => $prev7Clicks,
|
||||
'last7Uniques' => $last7Uniques,
|
||||
'prev7Uniques' => $prev7Uniques,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
use Filament\Support\Assets\Css;
|
||||
use Filament\Support\Facades\FilamentAsset;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Spatie\LaravelPackageTools\Package;
|
||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
||||
|
||||
@@ -47,4 +50,24 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
$this->app->singleton(ShortUrlService::class);
|
||||
$this->app->singleton(ShortUrlTracker::class);
|
||||
}
|
||||
|
||||
public function packageBooted(): void
|
||||
{
|
||||
FilamentAsset::register([
|
||||
Css::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
|
||||
], package: 'janczakb/filament-short-url');
|
||||
|
||||
// Automatically register scheduled tasks in the application scheduler
|
||||
$this->app->booted(function (): void {
|
||||
$schedule = $this->app->make(Schedule::class);
|
||||
|
||||
if (config('filament-short-url.pruning.enabled', true)) {
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
}
|
||||
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\IncrementVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -9,6 +13,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
@@ -151,12 +156,18 @@ class ShortUrl extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Bust the cache when the model is saved or deleted.
|
||||
* Bust the redirect cache when the model is saved or deleted.
|
||||
* Also invalidate the global overview link-count cache (forever cache)
|
||||
* on creation or deletion — the count changes only in those cases.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
|
||||
static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
|
||||
|
||||
// Bust the forever-cached link counts displayed in the global overview widget.
|
||||
static::created(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
|
||||
static::deleted(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
|
||||
}
|
||||
|
||||
/** @return Collection<int, static> */
|
||||
@@ -250,7 +261,7 @@ class ShortUrl extends Model
|
||||
/**
|
||||
* Resolve the targeted destination URL based on request headers/context.
|
||||
*/
|
||||
public function resolveDestinationUrl(\Illuminate\Http\Request $request): string
|
||||
public function resolveDestinationUrl(Request $request): string
|
||||
{
|
||||
$rules = $this->targeting_rules;
|
||||
|
||||
@@ -268,15 +279,16 @@ class ShortUrl extends Model
|
||||
if (str_contains($ua, 'android')) {
|
||||
return $rules['device']['android'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
return $rules['device']['desktop'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
if ($type === 'geo') {
|
||||
$countryCode = \Bjanczak\FilamentShortUrl\Services\ClientIpExtractor::getCountryCode($request);
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
if (! $countryCode) {
|
||||
// Try resolving via GeoIpService
|
||||
$ip = \Bjanczak\FilamentShortUrl\Services\ClientIpExtractor::getIp($request);
|
||||
$geo = app(\Bjanczak\FilamentShortUrl\Services\GeoIpService::class)->resolve($ip);
|
||||
$ip = ClientIpExtractor::getIp($request);
|
||||
$geo = app(GeoIpService::class)->resolve($ip);
|
||||
$countryCode = $geo['country_code'] ?? null;
|
||||
}
|
||||
|
||||
@@ -336,7 +348,7 @@ class ShortUrl extends Model
|
||||
|
||||
// Safe fallback: Dispatch async job so clicks are queued and not lost on cache clear
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
dispatch(new \Bjanczak\FilamentShortUrl\Jobs\IncrementVisitJob($this->id, $isUnique)->onConnection($connection ?: 'sync'));
|
||||
dispatch(new IncrementVisitJob($this->id, $isUnique)->onConnection($connection ?: 'sync'));
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -391,6 +403,7 @@ class ShortUrl extends Model
|
||||
foreach ($additional as $key => $val) {
|
||||
$base[$key] = ($base[$key] ?? 0) + $val;
|
||||
}
|
||||
|
||||
return $base;
|
||||
};
|
||||
|
||||
@@ -543,4 +556,111 @@ class ShortUrl extends Model
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache properties to hold preloaded buffered visits for the current request.
|
||||
*/
|
||||
protected static ?array $bufferedTotalVisits = null;
|
||||
|
||||
protected static ?array $bufferedUniqueVisits = null;
|
||||
|
||||
/**
|
||||
* Preload all buffered clicks in a single batch query for the entire request.
|
||||
* Prevents N+1 database queries even if database cache driver is used.
|
||||
*/
|
||||
protected static function loadAllBufferedVisits(): void
|
||||
{
|
||||
if (static::$bufferedTotalVisits !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::$bufferedTotalVisits = [];
|
||||
static::$bufferedUniqueVisits = [];
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// 1. Fetch the list of dirty IDs (URLs with pending buffered clicks) in one query
|
||||
$dirtyIds = [];
|
||||
try {
|
||||
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$dirtyIds = Redis::smembers($dirtyKey);
|
||||
} else {
|
||||
$dirtyIds = cache()->get($dirtyKey, []);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
if (empty($dirtyIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dirtyIds = array_unique(array_filter((array) $dirtyIds));
|
||||
|
||||
// 2. Build array of keys to fetch in a single cache store read
|
||||
$totalKeys = [];
|
||||
$uniqueKeys = [];
|
||||
foreach ($dirtyIds as $id) {
|
||||
$totalKeys[$id] = "{$prefix}total:{$id}";
|
||||
$uniqueKeys[$id] = "{$prefix}unique:{$id}";
|
||||
}
|
||||
|
||||
try {
|
||||
// Cache::many() is highly optimized (e.g. 1 database query for database store, or 1 MGET for Redis)
|
||||
$totals = cache()->many(array_values($totalKeys));
|
||||
$uniques = cache()->many(array_values($uniqueKeys));
|
||||
|
||||
foreach ($totalKeys as $id => $key) {
|
||||
static::$bufferedTotalVisits[$id] = (int) ($totals[$key] ?? 0);
|
||||
}
|
||||
foreach ($uniqueKeys as $id => $key) {
|
||||
static::$bufferedUniqueVisits[$id] = (int) ($uniques[$key] ?? 0);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total visits count, merging the database value with any buffered clicks in cache.
|
||||
* Prevents database N+1 queries.
|
||||
*/
|
||||
public function getTotalVisitsAttribute(): int
|
||||
{
|
||||
$dbValue = $this->attributes['total_visits'] ?? 0;
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return $dbValue;
|
||||
}
|
||||
|
||||
static::loadAllBufferedVisits();
|
||||
|
||||
$buffered = static::$bufferedTotalVisits[$this->id] ?? 0;
|
||||
|
||||
return $dbValue + $buffered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique visits count, merging the database value with any buffered clicks in cache.
|
||||
* Prevents database N+1 queries.
|
||||
*/
|
||||
public function getUniqueVisitsAttribute(): int
|
||||
{
|
||||
$dbValue = $this->attributes['unique_visits'] ?? 0;
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return $dbValue;
|
||||
}
|
||||
|
||||
static::loadAllBufferedVisits();
|
||||
|
||||
$buffered = static::$bufferedUniqueVisits[$this->id] ?? 0;
|
||||
|
||||
return $dbValue + $buffered;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class ShortUrlSettingsManager
|
||||
'geo_ip_driver' => config('filament-short-url.geo_ip.driver', 'headers'),
|
||||
'geo_ip_cache_ttl' => config('filament-short-url.geo_ip.cache_ttl', 86400),
|
||||
'geo_ip_timeout' => config('filament-short-url.geo_ip.timeout', 3),
|
||||
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', database_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'ga4_api_secret' => config('filament-short-url.ga4.api_secret'),
|
||||
'ga4_firebase_app_id' => config('filament-short-url.ga4.firebase_app_id'),
|
||||
'queue_connection' => config('filament-short-url.queue_connection', 'sync'),
|
||||
|
||||
Reference in New Issue
Block a user