Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f5480dea | ||
|
|
77116ebae4 | ||
|
|
db53540952 | ||
|
|
9c4d693b25 | ||
|
|
06e3934438 | ||
|
|
dd3e289be0 | ||
|
|
4d1ea3c9bb | ||
|
|
76019c8107 | ||
|
|
561d674237 | ||
|
|
4156269279 | ||
|
|
68194fb81b | ||
|
|
24101246f5 |
3
.gitignore
vendored
@@ -9,3 +9,6 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Internal maintainer notes (not shipped)
|
||||
/AUDIT_IMPLEMENTATION.md
|
||||
|
||||
363
CHANGELOG.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **janczakb/filament-short-url** are documented here.
|
||||
|
||||
Recent releases (v5.x) are summarized in [README.md](README.md#changelog). This file contains the full history for older versions and the complete **v5.2.0** release notes (including enterprise hardening follow-up, signed 2026-06-08).
|
||||
|
||||
---
|
||||
|
||||
## v5.2.3
|
||||
|
||||
> **Production Redis mode, stats scaling for high traffic, and Settings diagnostics.**
|
||||
|
||||
### Settings → Redis & queue diagnostics
|
||||
|
||||
- **Redis connection in Settings** — When **Queue Connection = `redis`**, configure host, port, password, database, and key prefix in **Settings → General**. Values are stored in `short_url_settings` and **override** `database.redis` + `queue.connections.redis` at runtime (independent of `CACHE_STORE`).
|
||||
- **Test Redis connection** — PING + pipelined `INCR`/`SADD` probe (same Redis primitives as visit counters). Uses unsaved form values via preview overrides.
|
||||
- **Test queue worker** — Dispatches `VerifyQueueWorkerJob` on the selected connection/queue and waits up to 12 s for a worker to process it; reports the exact `php artisan queue:work {connection} --queue={name}` command on failure.
|
||||
- **Unified queue callout** — Single info box per async driver with a **dynamic** worker command from current Settings (no duplicate boxes; hidden entirely for `sync`).
|
||||
- **`withPreviewSettings()`** — Settings test buttons apply form state temporarily without saving.
|
||||
|
||||
### Stats scaling (`queue_connection = redis`)
|
||||
|
||||
- **`StatsScalingProfile`** — Central profile: auto counter buffering, dedicated Redis counters, Redis today buffer, SQL micro-cache for sync mode.
|
||||
- **`PluginRedisConnection`** — Resolves Redis from `queue.connections.redis` (phpredis/Predis), not from `CACHE_STORE`.
|
||||
- **`VisitCounterBuffer`** — Unified buffered totals/uniques: dedicated Redis path when Settings queue is `redis`, Laravel cache path for sync + manual toggle.
|
||||
- **`TodayStatsBuffer`** — Pipelined Redis counters for today's summary and hourly charts; merged live in `HasStatsCache` without busting historical stats cache on every click.
|
||||
- **`StatsVisitRecorder`** — Hot-path stats side effects extracted from the visit job; **`TrackShortUrlVisitJob`** no longer clears full stats cache per visit.
|
||||
- **`SyncBufferedCountersCommand`** — Refactored to flush via `VisitCounterBuffer` (Redis dirty-set + cache path).
|
||||
- **Live feed** — `LiveFeedBroadcaster` prefers plugin queue Redis over cache-store Redis (unchanged priority, now shares infra with counters/stats when queue is `redis`).
|
||||
|
||||
### Config & migrations
|
||||
|
||||
- **`config/filament-short-url.php`** — New `redis.*` defaults (`REDIS_HOST`, `REDIS_PORT`, …) used as fallbacks before Settings overrides.
|
||||
- **Service provider** — All package migrations registered in `hasMigrations()` including `2026_06_10_000001` and `2026_06_11_000001`.
|
||||
|
||||
### Tests
|
||||
|
||||
- **`tests/Feature/StatsScalingTest.php`** — Redis queue profile, today buffer, counter buffering.
|
||||
- **`tests/Feature/RedisSettingsTest.php`** — Config overrides, masked password preservation, sync worker test.
|
||||
- **`tests/Unit/PluginRedisConnectionTesterTest.php`** — Redis health-check service.
|
||||
|
||||
---
|
||||
|
||||
## v5.2.2
|
||||
|
||||
> **Enterprise hardening, airtight visit counters, trait refactor, and i18n audit.**
|
||||
|
||||
### Security
|
||||
|
||||
- **XSS** — `@json()` in pixel interstitial and Alpine stats widgets (replaces `addslashes()`).
|
||||
- **Atomic `max_visits`** — `VisitSlotReservation::reserveAtomicSlot()` with fast-path SQL and pessimistic lock near cap.
|
||||
- **Single-use bots** — BotDetector gate before consuming single-use links.
|
||||
- **Buffer double-count** — `revertBufferedIncrements()` before DB fallback in `HasVisitCounters`.
|
||||
- **API tenancy** — `owner_user_id` required on API keys when `scope_links_to_user` is enabled.
|
||||
- **IDOR** — `ResourceOwnershipValidator` for folder/tag/pixel on API writes.
|
||||
- **Redirect pipeline** — Limits before app interstitial; caps enforced when `track_visits=false`.
|
||||
|
||||
### Performance
|
||||
|
||||
- **`max_visits_pessimistic_remaining`** config (default 5).
|
||||
- **`LiveFeedBroadcaster`** — Redis source priority: plugin **Settings → Queue Connection = redis** first, then `CACHE_STORE=redis`; pub/sub push with PhpRedis, SSE poll fallback otherwise.
|
||||
- **`StatsCacheHelper`** — `Cache::lock` on stats miss.
|
||||
- **`preloadBufferedCountersForIds()`** — Batch buffer reads in list tables.
|
||||
|
||||
### Quality
|
||||
|
||||
- **Public stats** — Uniform 404; public-safe response subset.
|
||||
- **Octane** — A/B variant on request attributes.
|
||||
- **Filtered uniques** — Rollup merge in `FilteredStatsCollector`.
|
||||
- **`findByKey()`** — `domain_scope_id` index path.
|
||||
|
||||
### Maintainability
|
||||
|
||||
- **`HasStats` facade** — `HasVisitCounters`, `HasStatsCache`, `HasStatsQueries`, `HasSecurityStats`.
|
||||
- **i18n** — API, redirect, and visitor-facing strings use translation keys (EN + PL).
|
||||
|
||||
### Tests
|
||||
|
||||
- **`tests/Feature/WorldClassHardeningTest.php`** — Comprehensive audit regression suite.
|
||||
|
||||
---
|
||||
|
||||
## v5.2.1
|
||||
|
||||
> **Audit roadmap follow-up** — Authorization policy, API stats parity, SSE live feed, load baselines, and hardening tests.
|
||||
|
||||
### Authorization
|
||||
|
||||
- **`ShortUrlPolicy`** — `view`, `update`, `delete`, `restore`, `forceDelete`, and `manageSettings` aligned with `scope_links_to_user` / `user_id`. Auto-registered in `FilamentShortUrlServiceProvider` when the host has no custom policy.
|
||||
- **Stats page** — `ViewShortUrlStats` calls `authorize('view', $record)`.
|
||||
|
||||
### REST API
|
||||
|
||||
- **Filtered stats** — `GET /links/{idOrKey}/stats` passes query filters to `FilteredStatsCollector` (same dimensions as the Filament stats dashboard).
|
||||
- **`ApiStatsFilterParser`** — Parses `CrossDimensionalStatsEngine::FILTER_KEYS` plus aliases `country` → `country_code`, `device` → `device_type`.
|
||||
- **Response meta** — Returns `meta.date_from`, `meta.date_to`, and `meta.filters`.
|
||||
|
||||
### Live feed (SSE)
|
||||
|
||||
- **`ShortUrlLiveFeedStreamController`** — Authenticated SSE endpoint at `GET /short-url/live-feed/{shortUrl}/stream`.
|
||||
- **Widget** — Alpine `EventSource` + `ShortUrlLiveFeedWidget::onStreamUpdate()` replaces Livewire polling.
|
||||
- **Config** — `live_feed.sse_interval_seconds` (default 3), `live_feed.sse_max_duration_seconds` (default 120).
|
||||
|
||||
### Load & stress baselines
|
||||
|
||||
- **`php artisan short-url:stress-redirect {key}`** — In-process redirect benchmark (avg / min / p95 / max ms).
|
||||
- **`scripts/k6/redirect-baseline.js`** — k6 HTTP load script with setup probe, configurable VUs/duration, and p95 thresholds.
|
||||
|
||||
### Tests
|
||||
|
||||
- **`tests/Feature/RoadmapHardeningTest.php`** — Policy scoping, API stats filters, webhook HMAC E2E via `Http::fake()`, `max_visits` burst guard, SSE stream authorization, stress command smoke test.
|
||||
|
||||
---
|
||||
|
||||
## v5.2.0
|
||||
|
||||
> **Main release theme: SEO & Social + integration hardening** — Open Graph metadata, link cloaking, search indexing control, expanded REST API, production security fixes, and **enterprise hardening** (GA4 MP, Geo-IP, custom domain DNS gate).
|
||||
|
||||
### Stats & security hardening (audit follow-up)
|
||||
|
||||
- **Filtered stats optimizer** — `FilteredStatsCollector` replaces 15+ raw GROUP BY queries with daily JSON rollups + single summary + cursor pass.
|
||||
- **Cross-dimensional daily rollups** — `CrossDimensionalStatsEngine` stores single- and two-filter slices in `short_url_daily_stats` (`cross_dimensional_stats`, `cross_filter_pairs`, `filter_qr_counts`); aggregation uses one cursor pass instead of 11 GROUP BY queries; re-aggregation backfills NULL cross columns.
|
||||
- **Stats cache helper** — `StatsCacheHelper` wraps remember/forget with driver-agnostic fallbacks (file, database, Redis, Memcached, array).
|
||||
- **Security widget cache** — `getSecurityBreakdownStats()` with daily `all_visits_count` / `bot_visits_count` / `proxy_visits_count` rollups.
|
||||
- **World map** — Always routes through `getCachedStats()`.
|
||||
- **VPN fail-closed** — When `block_with_403`, detection timeout/error blocks access.
|
||||
- **Public stats password** — Accepted only via POST body or `Authorization` header (not query string).
|
||||
- **Custom domain legacy URLs** — `/s/{key}` → 301 → `/{key}` on verified custom domains.
|
||||
|
||||
### SEO, Open Graph & Link Cloaking
|
||||
|
||||
- **New SEO & Social tab** — `og_title`, `og_description`, `og_image`, `do_index`, `is_cloaked`, live social preview sidebar.
|
||||
- **Custom Open Graph meta** — Per-link OG/Twitter tags for Facebook, LinkedIn, X, Slack, WhatsApp, and other preview bots.
|
||||
- **Search Engine Indexing (`do_index`)** — Per-link toggle; when off, all responses emit `noindex, nofollow`.
|
||||
- **Link Cloaking (`is_cloaked`)** — Iframe embedding on your short-link domain for humans; standalone OG page for crawlers.
|
||||
- **OG meta scraper** — SSRF-safe streaming fetch (`GET /short-url/scrape-meta`).
|
||||
- **OG image pipeline** — WebP conversion, temp storage, promotion to permanent disk on save.
|
||||
- **Iframeable pre-check** — `POST /short-url/check-iframeable` with redirect-chain validation.
|
||||
|
||||
### REST API
|
||||
|
||||
- **Bulk create** — `POST /api/short-url/links/bulk` (max 100).
|
||||
- **Upsert** — `PUT /api/short-url/links/upsert` by `external_id` or `url_key` + `destination_url`.
|
||||
- **Helpers** — `GET /links/exists`, `/links/random`, `/links/info`.
|
||||
- **CSV export** — `GET /links/{idOrKey}/visits/export`.
|
||||
- **Tags & Folders CRUD** — `/api/short-url/tags`, `/api/short-url/folders`.
|
||||
- **Per-key owner scoping** — Optional `owner_user_id` on API keys.
|
||||
- **Link-level UTM** — `utm_*` and `ref` stored on the link and merged on redirect.
|
||||
- **Expanded serializer** — `external_id`, UTM fields, tags, folder, archive, public stats flags.
|
||||
- **Visits, bulk delete/update** — Paginated visits log, bulk delete by IDs/keys, bulk patch.
|
||||
|
||||
### Public Stats
|
||||
|
||||
- **Shareable endpoint** — `GET /short-url/public-stats/{url_key}` with optional password (`public_stats_enabled`, `public_stats_password`).
|
||||
|
||||
### Security & Reliability
|
||||
|
||||
- **Password hashing** — Link passwords hashed at rest; legacy plain-text re-hashed on save.
|
||||
- **DNS-aware SSRF guard** — Webhooks, OG scraper, iframe checker block hostnames resolving to private IPs.
|
||||
- **`OutboundUrl` rule** — Shared validation for outbound URLs.
|
||||
- **Safe Browsing fail-closed** — Unreachable API rejects URLs when checking is enabled.
|
||||
- **Global webhook toggle** — `global_webhook_enabled` must be on for global webhooks to fire.
|
||||
- **`log-error` hardened** — Auth + throttle + validation.
|
||||
- **Bot hardening** — Bot visit exclusion, optional click dedup, Googlebot IP verify, `?bot=1` secret.
|
||||
- **Single-use fix** — No visit recorded after single-use consumption.
|
||||
- **Redis counter fix** — Buffered stats sync with Redis cache driver.
|
||||
|
||||
### Settings GUI
|
||||
|
||||
- **Analytics & Bot Detection** — Click deduplication, Googlebot verify, bot debug secret in **Settings → Advanced**.
|
||||
|
||||
### Database (safe upgrade)
|
||||
|
||||
- **Migration `2026_06_08_000001`** — Adds nullable `external_id`, `utm_*`, `ref`, `public_stats_*`, and index on `short_url_visits (short_url_id, ip_hash)`. No existing data modified.
|
||||
- **Migration `2026_06_09_000001`** — Adds `domain_scope_id`, composite unique `(url_key, domain_scope_id)`, FK on `custom_domain_id` (`ON DELETE SET NULL`), admin/aggregation indexes, and JSON stats columns on `short_url_daily_stats`. Existing rows backfilled (`domain_scope_id = custom_domain_id` where set, else `0`).
|
||||
- **Cross-database** — No `->after()` hints, no DB-specific `ENUM`; runs on SQLite, MySQL, and PostgreSQL.
|
||||
|
||||
### Performance & reliability audit
|
||||
|
||||
- **`domain_scope_id` + composite unique** — Same slug on default domain and custom domains without collision.
|
||||
- **Custom domain routing** — Root-level URLs on verified domains; reserved segments blocked (`api`, `admin`, `auth`, …).
|
||||
- **`getRealTimeTotalVisits()`** — Merges buffered counters for accurate `max_visits` / single-use checks with cached redirect models.
|
||||
- **HasStats / aggregation** — Incremental daily aggregation, bot/proxy filters, chunked prune, Redis dirty-set fix, scoped buffer reads, unique metric via `COUNT(DISTINCT ip_hash)`.
|
||||
- **`SyncBufferedCountersCommand`** — Atomic batch flush; temp keys cleaned after commit.
|
||||
- **`VerifyCustomDomainsCommand`** — Scheduled DNS re-verification for custom domains.
|
||||
- **API** — Transactions on bulk/upsert, `ShortUrlCacheInvalidator`, scoped `exists`, hashed-only API keys, public stats throttle.
|
||||
- **Security** — `AuthenticateShortUrlApi` drops plaintext legacy keys; public stats password from query/body/Authorization.
|
||||
- **Settings** — VPN cache TTL + timeout in GUI; settings cache 3600s; queue default remains **`sync`** (opt-in `database`/`redis`).
|
||||
- **Support classes** — `HostNormalizer`, `ShortUrlCacheInvalidator`, `LinkUtmMerger`, `ApiLinkScope`, `VisitCsvExporter`.
|
||||
- **Verification doc** — Full itemized checklist in **[AUDIT_IMPLEMENTATION.md](AUDIT_IMPLEMENTATION.md)**.
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **`ShortUrlRedirectHandler`**, **`RedirectUrlResolver`**, **`OgMetaPresenter`**, **`StatsSqlHelper`**, **`SafeUrl` rule**.
|
||||
|
||||
### Enterprise hardening (follow-up — 2026-06-08)
|
||||
|
||||
> **Signed:** Bartek Janczak — post-audit production hardening before v5.2.0 tag.
|
||||
|
||||
#### GA4 Measurement Protocol (enterprise)
|
||||
|
||||
- **`Ga4MeasurementProtocolService`** — Extracted from `TrackShortUrlVisitJob`; singleton registered in service provider.
|
||||
- **Full MP payload** — `timestamp_micros`, `session_id`, `engagement_time_msec`, standard events `page_view` + `click`, custom `short_url_visit`.
|
||||
- **Measurement ID validation** — Rejects invalid `ga_tracking_id` format before HTTP; logs and skips send.
|
||||
- **Settings Test connection** — Requires real `G-XXXXXXXXXX` (or Firebase App ID); validates via GA4 debug collector with the configured secret (not placeholder `G-XXXXXXXXXX`).
|
||||
- **Privacy-safe `client_id`** — Deterministic SHA-256 hash of IP + User Agent (unchanged semantics, now in dedicated service).
|
||||
|
||||
#### Geo-IP foolproofing
|
||||
|
||||
- **Headers driver auto-trust** — Saving `geo_ip_driver = headers` forces `trust_cdn_headers = true` in DB and runtime config.
|
||||
- **Offline fallback chain** — When CDN headers are missing, Headers driver falls back to MaxMind, then ip-api (instead of returning empty geo).
|
||||
- **ip-api rate limit** — Laravel `RateLimiter` guard: max 40 lookups/minute (`fsu_geo_ip_api:{YmdHi}`).
|
||||
- **Settings UI** — GeoIP tab info callout explains auto-trust and fallback behaviour.
|
||||
|
||||
#### Custom domains (DNS gate)
|
||||
|
||||
- **Multi-record verification** — All A records resolved; multiple CNAME targets; IP match via `array_intersect`; CNAME chain to `app.url` host.
|
||||
- **Activation enforcement** — `custom_domains.enforce_dns_on_activate` (default `true`, env `SHORT_URL_CUSTOM_DOMAIN_ENFORCE_DNS`); saving with `is_active=true` without DNS → auto-deactivate.
|
||||
- **Removed `SERVER_ADDR` fallback** — Verification relies on DNS resolution against `app.url` only.
|
||||
|
||||
#### API & settings
|
||||
|
||||
- **Upsert + `lock_url_key`** — `UpsertShortUrlRequest` blocks changing `url_key` and `custom_domain_id` when lock is enabled (closes panel/API parity gap).
|
||||
- **`ShortUrlSettingsManager`** — `last_aggregation_date` in system keys; secret preservation on masked save; `trust_cdn_headers` derived when driver is headers.
|
||||
|
||||
#### Stats & reliability audit (P0–P2)
|
||||
|
||||
- **Redis dirty-set prefix** — `SyncBufferedCountersCommand` uses store prefix for dirty ID set.
|
||||
- **Webhook SSRF** — `SendWebhookJob`: `allow_redirects => false`, rejects 3xx responses.
|
||||
- **Buffer fallback** — `HasStats::incrementVisits()` DB fallback when dirty-ID tracking fails.
|
||||
- **Unique visits** — Human clicks from daily rollups; no DISTINCT override inflation.
|
||||
- **Cross-filter stats** — Two-filter pairs in aggregation; gap fill in `FilteredStatsCollector`.
|
||||
- **Per-link webhooks** — Respects `webhook_events` on link model.
|
||||
- **Octane** — `GeoIpService::flush()`, `ShortUrl::flushBufferedCounterMemory()` on request terminate.
|
||||
- **Security** — Redirect HTML XSS fix; public stats rate limit; visit logs default to stats-counted scope.
|
||||
- **Tests** — `EnterpriseHardeningTest.php`, `StatsAuditHardeningTest.php`; run `php artisan test packages/filament-short-url/tests/ --compact` in the host app.
|
||||
|
||||
#### Critical audit hardening (follow-up — 2026-06-08)
|
||||
|
||||
- **Stateless redirect default** — Main `/s/{key}` route uses `throttle:120,1` only (no `web` middleware). Password flow stays on `/s-auth/{key}` with explicit `web`.
|
||||
- **Filament `lock_url_key` server-side** — `LockedUrlKeyGuard` sanitizes Filament save data and blocks model persistence when key/domain change is attempted (parity with API).
|
||||
- **Multi-tenant Filament scope** — `LinkUserScope` filters `ShortUrlResource` by `user_id` when `scope_links_to_user` is enabled (default).
|
||||
- **Custom domain ownership in API** — `CustomDomainValidator` ensures API keys/users can only assign domains they own.
|
||||
- **Scoped `exists` API** — `custom_domain_id` + `domain_scope_id` in response; checks per domain scope.
|
||||
- **Safe Browsing on redirect** — `SafeBrowsingService::isSafeCached()` re-checks destination at redirect time (configurable TTL).
|
||||
- **Webhook signing required** — Global webhooks require `webhook_signing_secret` when `webhook_signing_required` is true (default); jobs skip unsigned dispatch.
|
||||
- **Hot path optimizations** — Single domain resolution passed to `findByKey()`; pixels lazy-loaded; `max_visits` enforced under row lock before tracking.
|
||||
- **Docs** — README claims corrected (no sub-15ms; dub.co parity table scoped; production checklist; sync queue default documented).
|
||||
- **Tests** — `SecurityHardeningFixesTest.php`, `LockedUrlKeyGuardTest.php`.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **Aggregator Performance Tuning** — Restructured daily aggregation SQL queries to filter only active URL IDs, leveraging composite indexes and running native DB aggregations.
|
||||
- **Logo Upload Authorization** — Secured the logo upload API with resource-level permission and policy checks.
|
||||
- **DB-Free Registration Phase** — Deferred settings overrides to the service provider boot phase to prevent database errors during offline configuration caching.
|
||||
- **Conditional Redirection Fallback** — Added configurations to optionally disable global fallback route registration.
|
||||
|
||||
## v3.5.0
|
||||
|
||||
- **User Attribution** — Short URLs now record the creator. Their avatar and hover card (name + email) are displayed in the list table.
|
||||
- **Relative Date Badges** — Creation dates show compact relative strings (`2h`, `5d`, `3mo`). Hover to see the precise date and timezone.
|
||||
- **Keyboard Shortcuts** — Hover any table row and press `E` (edit), `Q` (QR code), `I` (share), `S` (statistics), or `X` (delete).
|
||||
- **Unified Action Dropdown** — All per-row actions consolidated under a single 3-dot button with keyboard shortcut badges.
|
||||
- **Row Click → Statistics** — Clicking a table row navigates directly to the link's Statistics page.
|
||||
- **A/B Split Testing** — Weighted traffic rotation in root-level links and nested targeting rules, with drag-based sliders and a one-click "Balance weights" action.
|
||||
- **Variant Analytics** — Tracks which A/B variant was served and shows a "Variant Clicks Distribution" chart in the analytics dashboard.
|
||||
- **Query Optimization** — Composite DB index on the visits table; queries filter by indexed `country_code`; Eloquent hydration bypassed with `toBase()->get()` for large sets.
|
||||
- **GDPR IP Anonymization** — Opt-in IP masking (IPv4/IPv6) with SHA-256 hashing for unique visitor identification, managed via Settings.
|
||||
- **Google Safe Browsing in REST API** — Safety checks now apply to all incoming URLs in the API (destination, A/B variants, targeting rules).
|
||||
- **Settings URL Cleanup** — Tab query parameters are now human-readable (e.g. `?tab=qr` instead of `?tab=qr-defaults::data::tab`).
|
||||
- **Analytics Enhancements** — QR Scan Conversion Rate card with period-over-period delta, browser/OS version subtexts, A/B click-share vs. weight comparison.
|
||||
|
||||
## v3.3.0
|
||||
|
||||
- **Advanced Multi-Filter Targeting Engine** — Replaced the legacy single-strategy selection panel with a highly flexible rule engine supporting custom `AND` / `OR` logic matching.
|
||||
- **Granular Filter Categories** — Added support for filtering by devices (Desktop, Mobile, Tablet), platform operating systems (Windows, macOS, Linux, iOS, Android, Fire OS), countries (multiselect with search), and browser languages (multiselect with search).
|
||||
- **Client-Side Flag Icons** — Integrated high-quality country flag icons dynamically loaded from a trusted CDN (`flagcdn.com`) inside both the Filament form targeting dropdown and the analytics country statistics widget.
|
||||
- **Redirection Performance Boost** — Bypassed full user agent parsing (which involves regular expression scanning for versions) during redirections by introducing specialized fast-path `getDeviceType` and `getOs` helper functions.
|
||||
- **Dynamic Legacy Data Adapter** — Added automatic on-the-fly hydration and upgrade of legacy database records to the new rule format when loaded in the Filament form.
|
||||
|
||||
## v3.2.0
|
||||
|
||||
- **Expanded Developer REST API** — Added new endpoints to inspect single short URLs (`GET /api/short-url/links/{idOrKey}`), fetch real-time click metrics (`GET /api/short-url/links/{idOrKey}/stats`), and fully update links programmatically (`PUT/PATCH /api/short-url/links/{idOrKey}`). Enabled flexible lookup dynamically by ID or URL key.
|
||||
- **REST API Throttling & Rate Limiting** — Added route middleware throttling for the Developer REST API. Current default: `throttle:120,1` (configurable via `filament-short-url.middleware`); per-key limits default to 60 req/min.
|
||||
- **Unified Validation System** — Cleaned up and unified API and admin panel form validation, supporting advanced fields like granular logging parameters (`track_visits`, `track_browser_language`, etc.), custom GA4 tracking IDs (`ga_tracking_id`), and auto-open deep linking options (`auto_open_app_mobile`).
|
||||
- **Media Controller Separation** — Refactored internal logo uploads and serving routes out of the public REST API controller into a dedicated `ShortUrlLogoController` (Single Responsibility compliance).
|
||||
- **Alpine.js Webhook Payload Preview** — Replaced the Filament package CodeEditor component with a custom, high-reliability dark-mode HTML/CSS component featuring live code highlighting and copy-to-clipboard functionality powered by Alpine.js.
|
||||
|
||||
## v3.1.0
|
||||
|
||||
- **Database-Backed & Cached Settings** — Relocated user configuration from local JSON files to the database (`short_url_settings` table) with automatic caching and zero-downtime migration of legacy settings.
|
||||
- **High-Performance Aggregations** — Completely refactored the statistics aggregator command to run optimized GROUP BY queries directly in the database, reducing memory usage (OOM protection) to near zero.
|
||||
- **Secure Hashed API Keys** — API keys are now stored securely as SHA-256 hashes, verified using constant-time comparisons (`hash_equals()`), and displayed only once upon generation.
|
||||
- **HMAC Signed Webhooks** — Webhook payloads are now optionally signed with a configured secret key and verified via the `X-ShortUrl-Signature` header.
|
||||
- **Privacy-Safe GA4 Client IDs** — Replaced random UUIDs with deterministic client IDs based on hashed visitor IP and User Agent, ensuring session integrity in Google Analytics without storing raw visitor data.
|
||||
- **Browser Cache Prevention for Limited Links** — Force temporary `302` redirects automatically for single-use, max-visit, or expiring links to prevent browsers from caching redirects and bypassing tracking logic.
|
||||
- **Proxy Detection Optimization** — Implemented an aggressive 800ms timeout for external proxy checkers and reduced cache time for transient rate-limit failures to 60 seconds.
|
||||
|
||||
## v3.0.0
|
||||
|
||||
- **Native App Linking (Mobile Auto-Open)** — Automatically match and redirect mobile visitors directly inside 24+ native mobile apps (such as WhatsApp, YouTube, TikTok, Instagram, Spotify, etc.) using custom schemes, complete with a glassmorphic redirect page and a live interactive matching preview widget.
|
||||
- **Global Deep Linking (Universal Links & App Links)** — Easily serve iOS `apple-app-site-association` and Android `.well-known/assetlinks.json` configuration files directly from your root domain to support OS-level native integration (disabled by default, managed via Settings).
|
||||
- **Central Retargeting Pixel Registry** — Introduced a premium Many-to-Many pixel management registry. Define pixels centrally (Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, Pinterest Tag) and easily associate them with short links via the Filament panel or the REST API.
|
||||
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
|
||||
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
|
||||
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
|
||||
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
|
||||
|
||||
## v2.0.0
|
||||
|
||||
- **Interactive QR Code Designer Branding Logo** — Upload custom brand logos inside the QR designer canvas in Filament. Configure logo sizing, margins, shapes (square/circle), and toggle dot backing removal to prevent dots overlapping with the logo.
|
||||
- **Dedicated QR Code Scan Tracking** — Differentiates visitor clicks from physical QR code scans by dynamically appending source tags (`?source=qr`). Added a new database tracking column (`is_qr_scan` on visits, `qr_scans` on short URLs, and `qr_visits_count` on daily stats). Displays a dedicated scans counter badge in the Filament list table.
|
||||
- **Browser Language Detection & Statistics** — Captures visitor browser preferred language headers (`browser_language` field) and aggregates them into the daily stats table. Displays a new "Top Languages" widget breakdown in the link statistics dashboard.
|
||||
- **High-Traffic Performance Safeguards & Robust Rollbacks** — Atomic database transactions for buffered counter updates with fail-safe rollback that restores cache values in case of DB connection failures. Prevents N+1 queries by preloading request-wide counters in a single batch cache lookup.
|
||||
- **Support for Empty Route Prefix (Root-Level URLs)** — Enhanced `getShortUrl()` to support empty route prefixes without generating double slashes (e.g. `domain.com/abc123` instead of `domain.com//abc123`).
|
||||
|
||||
## v1.7.0
|
||||
|
||||
- **Role-based Settings Access Control** — New `authorizeSettingsUsing(Closure)` method on the plugin to restrict who can access the Settings page. Supports any callable returning a `bool`. Also auto-detects a `manageSettings` method on a registered `ShortUrl` policy. The Settings button in the table header is hidden automatically when access is denied.
|
||||
|
||||
## v1.6.0
|
||||
|
||||
- **Google Safe Browsing Integration** — Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges.
|
||||
- **VPN / Proxy / Bot Filtering** — Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean.
|
||||
- **Visitor World Map Widget** — Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details.
|
||||
|
||||
## v1.5.1
|
||||
|
||||
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled.
|
||||
|
||||
## v1.5.0
|
||||
|
||||
- **Social Retargeting Pixels** — Attach Meta Pixel, Google Tag (GA4/Ads), and LinkedIn Insight Tag to any short URL. A premium glassmorphic interstitial page executes pixel scripts in the visitor's browser before forwarding them to the destination. Enables building remarketing audiences even on external domains.
|
||||
- **Developer REST API** — Full `GET /api/short-url/links`, `POST /api/short-url/links`, and `DELETE /api/short-url/links/{id}` endpoints with API Key authentication (managed via Settings UI). Supports creating links with all available attributes including pixels and webhooks.
|
||||
- **Webhook System** — Real-time HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events. Configurable per-link or globally. Dispatched asynchronously via `SendWebhookJob` with 3-attempt retry policy and 10-second backoff.
|
||||
- **Settings: API & Webhooks Tab** — New settings tab to manage global webhook URL, monitored event types, and developer API key management with name labels and per-key activation toggles.
|
||||
|
||||
## v1.4.0
|
||||
|
||||
- **Validity Date Ranges (From-To)** — Set activation dates (`activated_at` and `expires_at`) to control exactly when a short link is active.
|
||||
- **Custom Visit Limit Counters** — Define a custom maximum visit limit (`max_visits`) after which a link automatically expires (e.g., active for 3 hits).
|
||||
- **Custom Expiration Fallbacks** — Redirect expired/inactive visitors to a custom `expiration_redirect_url` rather than showing a static 410 Gone error page.
|
||||
- **Fluent Builder APIs** — Fluent method additions (`activatedAt()`, `deactivatedAt()`, `maxVisits()`, `expirationRedirectUrl()`) in the developer query builder.
|
||||
|
||||
## v1.3.0
|
||||
|
||||
- **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.
|
||||
|
||||
## v1.2.0
|
||||
|
||||
- **Password-protected links** — Session-based unlock flow with a styled prompt page.
|
||||
- **Redirect warning pages** — Interstitial security screen before external redirects.
|
||||
- **Smart targeting** — Device-based, Country/Geo-based, and A/B weighted rotation rules per link.
|
||||
- **Rate limiting** — Configurable per-IP redirect throttling with 429 responses.
|
||||
- **Daily stats aggregation** — Nightly `short-url:aggregate-and-prune` command for scalable log management.
|
||||
- **Extended Settings GUI** — New "Performance & Security" tab for aggregation and rate limiting configuration.
|
||||
- **Polish translations** — Full `pl` locale support for all new features.
|
||||
510
LICENSE
@@ -1,58 +1,476 @@
|
||||
Custom Source-Available & Dual-Commercial License
|
||||
================================================================================
|
||||
SOURCE-AVAILABLE & DUAL-COMMERCIAL LICENSE — VERSION 1.3
|
||||
janczakb/filament-short-url (the "Software")
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2026 Bartek Janczak. All rights reserved.
|
||||
Copyright (c) 2026 Bartłomiej Janczak. All rights reserved.
|
||||
Contact: barek122@gmail.com
|
||||
|
||||
IMPORTANT: PLEASE READ THIS LICENSE AGREEMENT CAREFULLY. BY DOWNLOADING, INSTALLING, OR USING THE SOFTWARE, YOU AGREE TO BE BOUND BY ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, OR USE THE SOFTWARE.
|
||||
IMPORTANT — READ CAREFULLY BEFORE DOWNLOADING, INSTALLING, OR USING THIS
|
||||
SOFTWARE. BY TAKING ANY OF THOSE ACTIONS, YOU UNCONDITIONALLY ACCEPT EVERY
|
||||
TERM AND CONDITION OF THIS AGREEMENT. IF YOU DO NOT ACCEPT, DO NOT DOWNLOAD,
|
||||
INSTALL, OR USE THE SOFTWARE IN ANY WAY.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
1. DEFINITIONS
|
||||
-------------------------------------------------------------------------------
|
||||
"Software" means the "Short URL Manager" PHP/Filament package, including all source code, modules, documentation, migrations, updates, and modified versions thereof.
|
||||
"Copyright Holder" means Bartek Janczak.
|
||||
"Commercial Distribution" means selling, renting, leasing, sublicensing, or otherwise distributing the Software (in whole or in part, or embedded into a larger work, boilerplate, theme, SaaS platform, or application) to third parties for monetary gain, fees, or any commercial consideration.
|
||||
"Internal Commercial Use" means deploying and using the Software within your own business operations or building a custom website/system for a single client, where the client does not resell the underlying code.
|
||||
This License is intended for professional and business use (B2B). You represent
|
||||
that You are acquiring the Software for professional purposes and not as a
|
||||
consumer acting outside Your trade, business, craft, or profession, to the
|
||||
extent such qualification is relevant under applicable law.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
2. GRANT OF FREE LICENSE (NON-DISTRIBUTION ONLY)
|
||||
-------------------------------------------------------------------------------
|
||||
Subject to compliance with Section 3 (Restrictions), the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
|
||||
a) Download, install, and use the Software free of charge for personal, educational, internal business purposes, or Internal Commercial Use across an unlimited number of projects.
|
||||
b) Modify the Software solely for your own private, internal, or Internal Commercial Use, provided that any such modifications are kept private and are never distributed publicly.
|
||||
================================================================================
|
||||
SECTION 1 — DEFINITIONS
|
||||
================================================================================
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
3. STRICT RESTRICTIONS & PROHIBITIONS
|
||||
-------------------------------------------------------------------------------
|
||||
Unless you have obtained an explicit, written Commercial License from the Copyright Holder pursuant to Section 4, you are STRICTLY PROHIBITED from:
|
||||
a) Public Redistribution: Publishing, hosting, or sharing the Software, in whole or in part, or any modified versions thereof, in any public repository (including but not limited to public repositories on GitHub, GitLab, Bitbucket) or public package registries (such as Packagist).
|
||||
b) Commercial Exploitation & Bundling: Engaging in any Commercial Distribution. You may not bundle, embed, or include the Software, in whole or in part, or any modified versions thereof, into any product, theme, extension, boilerplate, framework, or software application that is offered for sale, license, subscription, or commercial distribution to third parties.
|
||||
c) Relabeling: Publishing, distributing, or sharing the Software under a different author, vendor, or package name.
|
||||
d) Sublicensing: Granting any third party the right to resell, redistribute, or further sublicense the Software.
|
||||
1.1 "Software" means the PHP/Laravel/Filament package published by Bartłomiej
|
||||
Janczak under the identifier janczakb/filament-short-url, including all
|
||||
source code, compiled assets, database migrations, configuration files,
|
||||
documentation, and any future updates, patches, or versions thereof.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
4. DUAL-LICENSING & COMMERCIAL LICENSING REQUIREMENT
|
||||
-------------------------------------------------------------------------------
|
||||
Any individual, entity, or organization wishing to engage in Commercial Distribution, or use the Software in a manner not expressly permitted by Section 2, MUST secure a separate, written Commercial License from the Copyright Holder prior to such use or distribution.
|
||||
1.2 "Copyright Holder" means Bartłomiej Janczak, sole and exclusive author
|
||||
and owner of all intellectual property rights in and to the Software,
|
||||
except for third-party components governed by Section 6.4.
|
||||
|
||||
To inquire about purchasing a Commercial License or to request written authorization, please contact the Copyright Holder via email at: barek122@gmail.com
|
||||
1.3 "You" (or "Your") means the individual or legal entity exercising rights
|
||||
under this License.
|
||||
|
||||
Commercial Licenses may be subject to a licensing fee, revenue-sharing model, or other terms as agreed upon in writing by the Copyright Holder. Any Commercial Distribution attempted without a valid, active, written Commercial License from the Copyright Holder is null and void, constitutes a material breach of this Agreement, and is a direct infringement of international copyright laws.
|
||||
1.4 "Official Distribution Channels" means distribution points operated or
|
||||
expressly authorized in writing by the Copyright Holder for distributing
|
||||
the Software to licensees, including without limitation:
|
||||
(a) the official GitHub repository maintained by the Copyright Holder;
|
||||
(b) the janczakb/filament-short-url package listing on Packagist.org;
|
||||
(c) any mirror, release artifact, or update channel explicitly linked
|
||||
from the official repository or Copyright Holder website.
|
||||
Receiving a copy through an Official Distribution Channel at the Copyright
|
||||
Holder's direction is not "redistribution" by You.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
5. INTELLECTUAL PROPERTY & COPYRIGHT NOTICE
|
||||
-------------------------------------------------------------------------------
|
||||
The Software is licensed, not sold. The Copyright Holder retains all right, title, and interest in and to the Software, including all copyrights, patents, trade secrets, and other intellectual property rights. The original copyright notice, author attribution, and this entire permission notice must be included in all copies, substantial portions, or private forks of the Software.
|
||||
1.5 "Software Transfer" means, with respect to the Software or any portion
|
||||
thereof (including unmodified vendor copies, source trees, archives,
|
||||
Composer packages, Docker images, or deployment artifacts from which the
|
||||
Software can be extracted):
|
||||
(a) delivering, disclosing, publishing, or making accessible such copies
|
||||
to any third party;
|
||||
(b) granting any third party the right or practical ability to copy,
|
||||
redistribute, sublicense, republish, or further transfer the Software;
|
||||
(c) placing the Software in a repository, package index, shared drive,
|
||||
client portal, or artifact store where the client or any third party
|
||||
may obtain standalone copies independent of Your controlled runtime;
|
||||
(d) licensing or selling access to the Software itself, rather than to
|
||||
services or outcomes produced using the Software under this License.
|
||||
For the avoidance of doubt, Software Transfer does NOT include:
|
||||
(i) operating the Software on infrastructure controlled by You or Your
|
||||
client where the client receives no standalone copy and no rights
|
||||
to extract, republish, or sublicense the Software;
|
||||
(ii) delivering an integrated application to a client under Section
|
||||
1.7(b) when the client receives only runtime access and no rights
|
||||
described in subsections (a) through (d) above.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
6. TERMINATION
|
||||
-------------------------------------------------------------------------------
|
||||
This License and your right to use the Software will terminate automatically and immediately, without notice from the Copyright Holder, if you fail to comply with any of its terms or restrictions. Upon termination, you must immediately cease all use of the Software and destroy all copies, full or partial, in your possession.
|
||||
1.6 "Distinct Client Engagement" means one separately contracted scope of work
|
||||
for one end client identified in writing before deployment begins. Multiple
|
||||
projects, environments, brands, or business units of the same client
|
||||
constitute separate engagements unless expressly covered by one written
|
||||
statement of work.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
7. GOVERNING LAW AND JURISDICTION
|
||||
-------------------------------------------------------------------------------
|
||||
This License Agreement shall be governed by, and construed in accordance with, the substantive laws of Poland, without regard to its conflict of laws principles. Any legal action, suit, or proceeding arising out of or relating to this License shall be instituted exclusively in the competent courts located in Warsaw, Poland.
|
||||
1.7 "Permitted Free Use" means use that satisfies ALL of Sections 1.7(a) or
|
||||
(b), Section 1.12 (High-Volume Use limit), and every restriction in
|
||||
Section 3:
|
||||
(a) Personal, educational, research, or internal business use by a single
|
||||
natural person or a single legal entity solely for that entity's own
|
||||
internal operations, including staging and non-production environments
|
||||
controlled by that same entity;
|
||||
(b) Deployment of the Software for one Distinct Client Engagement as a
|
||||
work-for-hire or freelance project, provided that:
|
||||
(i) You remain the sole licensee and operator responsible for
|
||||
compliance with this License;
|
||||
(ii) the engagement uses no more than one (1) production environment
|
||||
for that client unless a Commercial License expressly authorizes
|
||||
more;
|
||||
(iii) You do not perform any Software Transfer to the client or any
|
||||
third party, except that the client may receive compiled or
|
||||
integrated application access without standalone Software copies
|
||||
or redistribution rights as described in Section 1.5;
|
||||
(iv) the client does not resell, license, distribute, or operate the
|
||||
Software as a product for third parties;
|
||||
(v) Qualified Events attributable to that engagement do not exceed
|
||||
the High-Volume Use threshold in Section 1.12.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
8. DISCLAIMER OF WARRANTY
|
||||
-------------------------------------------------------------------------------
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1.8 "Commercial Distribution" means any act of selling, renting, leasing,
|
||||
sublicensing, or otherwise transferring — for monetary gain, in-kind
|
||||
consideration, or any other commercial benefit — the Software, or any
|
||||
Derivative Work, or any Bundled Product, to one or more third parties.
|
||||
For the avoidance of doubt, Commercial Distribution includes but is not
|
||||
limited to:
|
||||
(a) selling or licensing a website, web application, theme, template,
|
||||
boilerplate, starter kit, or codebase that embeds or depends upon the
|
||||
Software, whether the Software is disclosed or concealed;
|
||||
(b) offering link-management, URL-shortening, QR-code, or redirect
|
||||
services as a paid SaaS, subscription, or multi-tenant product in
|
||||
which the Software's functionality is a material component;
|
||||
(c) providing the Software to a client under a contract where the client
|
||||
subsequently resells, licenses, or distributes the resulting product
|
||||
to end users;
|
||||
(d) performing any Software Transfer for consideration or as part of a
|
||||
commercial deliverable.
|
||||
|
||||
1.9 "Derivative Work" means any work that is based upon, incorporates,
|
||||
modifies, adapts, translates, or is otherwise derived from the Software
|
||||
or any substantial part thereof, regardless of the programming language,
|
||||
file format, or packaging used.
|
||||
|
||||
1.10 "Bundled Product" means any software application, plugin, extension,
|
||||
theme, template, framework, boilerplate, starter kit, digital product, or
|
||||
service offering in which the Software — in whole or in any part, modified
|
||||
or unmodified — is included, embedded, required as a dependency, or
|
||||
integrated in any manner, irrespective of whether the Software constitutes
|
||||
the primary component or a minor ancillary element.
|
||||
|
||||
1.11 "Qualified Event" means each HTTP redirect response served through the
|
||||
Software's redirect routes and each visit or click recorded by the
|
||||
Software's built-in tracking, counters, analytics, or daily-stats
|
||||
subsystems, whether or not visit logging is enabled for a given link.
|
||||
Automated health checks, load tests, and crawler traffic that the Software
|
||||
explicitly excludes from counters in its own code are excluded.
|
||||
|
||||
1.12 "High-Volume Use" means any deployment — whether internal, client,
|
||||
single-instance, or multi-instance — that generates more than 1,000,000
|
||||
(one million) Qualified Events in any calendar month attributable to
|
||||
deployments operated under Your license. Attribution includes all
|
||||
environments (production, staging, preview, and replicas) that You
|
||||
control or cause to be operated for the same entity or engagement.
|
||||
|
||||
1.13 "Repository" means any version-control hosting service or platform,
|
||||
including without limitation GitHub, GitLab, Bitbucket, Codeberg, or any
|
||||
self-hosted equivalent, whether public, private, or restricted.
|
||||
|
||||
1.14 "Package Registry" means any software distribution index, including
|
||||
without limitation Packagist, npm, PyPI, or any equivalent service.
|
||||
|
||||
================================================================================
|
||||
SECTION 2 — GRANT OF FREE LICENSE
|
||||
================================================================================
|
||||
|
||||
Subject strictly to the conditions and restrictions in Sections 3 and 4, the
|
||||
Copyright Holder grants You a worldwide, royalty-free, non-exclusive,
|
||||
non-sublicensable, non-transferable license to:
|
||||
|
||||
2.1 Obtain copies of the Software only through Official Distribution Channels
|
||||
or other channels expressly authorized in writing by the Copyright Holder,
|
||||
and install and use the Software solely for Permitted Free Use as defined
|
||||
in Section 1.7. Merely downloading or installing a copy from an Official
|
||||
Distribution Channel does not constitute redistribution by You.
|
||||
|
||||
2.2 Modify the Software solely for Your own private or internal use under
|
||||
Section 1.7(a) or (b), provided that all such modifications:
|
||||
(a) are kept strictly private and are never distributed, published,
|
||||
shared, disclosed, or made accessible to any third party in any form;
|
||||
(b) retain this entire License and all copyright notices without
|
||||
alteration.
|
||||
|
||||
2.3 Make a reasonable number of backup copies and deployment artifacts
|
||||
strictly necessary to operate Permitted Free Use, provided such copies
|
||||
remain under Your control and are not subject to Software Transfer.
|
||||
|
||||
No rights are granted except those expressly set out in this Section 2.
|
||||
All other rights are reserved exclusively by the Copyright Holder.
|
||||
|
||||
================================================================================
|
||||
SECTION 3 — STRICT RESTRICTIONS AND PROHIBITIONS
|
||||
================================================================================
|
||||
|
||||
The following acts are CATEGORICALLY AND UNCONDITIONALLY PROHIBITED unless
|
||||
You hold a valid, current Commercial License issued by the Copyright Holder
|
||||
under Section 5:
|
||||
|
||||
3.1 REDISTRIBUTION IN ANY FORM. You may not publish, upload, transmit,
|
||||
broadcast, mirror, host, or otherwise make available the Software — in
|
||||
whole or in any part, modified or unmodified — through any means,
|
||||
including but not limited to:
|
||||
(a) any public or private Repository, regardless of access controls,
|
||||
except as expressly permitted in Section 3.1(A);
|
||||
(b) any Package Registry, except Official Distribution Channels operated
|
||||
by the Copyright Holder;
|
||||
(c) file-sharing platforms, cloud storage, or peer-to-peer networks;
|
||||
(d) email, instant messaging, or any other communication channel.
|
||||
|
||||
(A) CONTRIBUTION FORKS — SOLE EXCEPTION. Public fork(s) of the official
|
||||
repository on GitHub are permitted exclusively to prepare and submit Pull
|
||||
Request(s) to the original repository. Each such fork:
|
||||
(i) must be created and maintained solely for contribution work;
|
||||
(ii) must not be used for hosting, demonstration, resale, or any
|
||||
independent product;
|
||||
(iii) must reproduce this License in full without modification;
|
||||
(iv) must be deleted or converted to private within thirty (30) calendar
|
||||
days of the earlier of: (A) all related Pull Request(s) being
|
||||
closed, merged, or rejected; or (B) ninety (90) calendar days of
|
||||
inactivity on that fork. Failure to comply constitutes a material
|
||||
breach of this License.
|
||||
|
||||
3.2 SOFTWARE TRANSFER. You may not perform any Software Transfer except as
|
||||
expressly allowed without transfer in Section 1.5(i)–(ii) and Section
|
||||
1.7(b)(iii).
|
||||
|
||||
3.3 BUNDLING AND COMMERCIAL EXPLOITATION. You may not:
|
||||
(a) include, embed, bundle, or integrate the Software, in whole or in any
|
||||
part, into any Bundled Product that is or will be subject to
|
||||
Commercial Distribution;
|
||||
(b) sell, license, rent, or otherwise commercially transfer any
|
||||
application, website, codebase, or digital product that contains or
|
||||
depends upon the Software, even if the Software represents only a
|
||||
minor or incidental component of the total offering;
|
||||
(c) use the Software as a component of any product offered as a template,
|
||||
boilerplate, or starter kit sold or licensed to multiple clients or
|
||||
end users, whether individually priced or as part of a bundle or
|
||||
subscription.
|
||||
|
||||
3.4 COMMERCIAL DISTRIBUTION. You may not engage in any act constituting
|
||||
Commercial Distribution as defined in Section 1.8.
|
||||
|
||||
3.5 HIGH-VOLUME USE without a Commercial License. You may not operate any
|
||||
system or service that constitutes High-Volume Use as defined in
|
||||
Section 1.12, including internal business use under Section 1.7(a).
|
||||
|
||||
3.6 RELABELING AND MISATTRIBUTION. You may not:
|
||||
(a) publish, distribute, or otherwise make available the Software under a
|
||||
different package name, author name, or vendor identity;
|
||||
(b) remove, obscure, alter, or misrepresent the copyright notice, license
|
||||
text, or author attribution in any copy or portion of the Software;
|
||||
(c) represent, imply, or permit others to believe that the Software was
|
||||
created by anyone other than Bartłomiej Janczak.
|
||||
|
||||
3.7 SUBLICENSING AND TRANSFER OF LICENSE RIGHTS. You may not grant any
|
||||
sublicense, assign any rights, or otherwise transfer this License or any
|
||||
rights granted hereunder to any third party. Hosting the Software for a
|
||||
client under Section 1.7(b) does not sublicense rights to the client.
|
||||
|
||||
3.8 CIRCUMVENTION. You may not structure any transaction, arrangement, or
|
||||
technical implementation with the intent or effect of circumventing any
|
||||
restriction in this Section 3, including without limitation by splitting a
|
||||
Bundled Product into artificially separate components, using an
|
||||
intermediary entity, masking the Software as a different dependency,
|
||||
or separating repositories solely to evade transfer or redistribution
|
||||
prohibitions.
|
||||
|
||||
3.9 COMPETING PRODUCTS. You may not use the Software, or any Derivative Work,
|
||||
as the core of a substantially similar link-management, URL-shortening,
|
||||
QR-code, or redirect-analytics product offered to third parties, whether
|
||||
open or closed source, except under a Commercial License.
|
||||
|
||||
================================================================================
|
||||
SECTION 4 — CONTRIBUTOR LICENSE AGREEMENT (CLA)
|
||||
================================================================================
|
||||
|
||||
4.1 By submitting any contribution (including but not limited to Pull
|
||||
Requests, patches, bug fixes, documentation edits, or any other
|
||||
modification) to the Software or to the official repository, You
|
||||
irrevocably grant to Bartłomiej Janczak a perpetual, worldwide,
|
||||
royalty-free, non-exclusive, sublicensable license to use, reproduce,
|
||||
modify, distribute, and sublicense Your contribution under any license
|
||||
terms the Copyright Holder deems appropriate, including proprietary terms.
|
||||
|
||||
4.2 You represent and warrant that:
|
||||
(a) You are the sole author of the contribution or have obtained all
|
||||
rights necessary to grant the license in Section 4.1;
|
||||
(b) the contribution does not infringe any third-party intellectual
|
||||
property rights;
|
||||
(c) the contribution is not subject to any license, agreement, or
|
||||
obligation that would conflict with the grant in Section 4.1;
|
||||
(d) if the contribution includes work created for an employer or client,
|
||||
You have authority to submit it under Section 4.1.
|
||||
|
||||
4.3 The Copyright Holder retains the right to reject any contribution without
|
||||
giving reasons and without incurring any obligation to the contributor.
|
||||
|
||||
================================================================================
|
||||
SECTION 5 — COMMERCIAL LICENSING
|
||||
================================================================================
|
||||
|
||||
5.1 Any use not expressly permitted by Section 2 — including Commercial
|
||||
Distribution, Software Transfer beyond Section 1.5(i)–(ii), Bundled
|
||||
Product distribution, High-Volume Use, competing products under
|
||||
Section 3.9, or any other commercial exploitation of the Software —
|
||||
requires a separate Commercial License issued by the Copyright Holder
|
||||
prior to such use.
|
||||
|
||||
5.2 To inquire about or obtain a Commercial License, contact:
|
||||
|
||||
Bartłomiej Janczak
|
||||
E-mail: barek122@gmail.com
|
||||
|
||||
5.3 Commercial Licenses are issued at the Copyright Holder's sole discretion
|
||||
and may be subject to a one-time fee, recurring royalty, revenue-sharing
|
||||
arrangement, or other terms as agreed in a written instrument signed by
|
||||
the Copyright Holder, including a qualified electronic signature or other
|
||||
form of document with legal effect equivalent to writing under applicable
|
||||
law. No Commercial License is effective until so signed or otherwise
|
||||
authenticated by the Copyright Holder.
|
||||
|
||||
5.4 Any act of Commercial Distribution, prohibited Software Transfer,
|
||||
Bundled Product distribution, High-Volume Use, or competing use under
|
||||
Section 3.9 without a valid Commercial License:
|
||||
(a) is null and void ab initio;
|
||||
(b) constitutes a material breach of this License;
|
||||
(c) constitutes direct infringement of the Copyright Holder's exclusive
|
||||
rights under the Act of 4 February 1994 on Copyright and Related
|
||||
Rights (Dz.U. 2022 poz. 2509, as amended) and applicable
|
||||
international copyright treaties, including the Berne Convention and
|
||||
the TRIPS Agreement;
|
||||
(d) entitles the Copyright Holder to seek all available remedies,
|
||||
including injunctive relief, actual damages, disgorgement of profits,
|
||||
and statutory damages to the maximum extent permitted by applicable law.
|
||||
|
||||
================================================================================
|
||||
SECTION 6 — INTELLECTUAL PROPERTY
|
||||
================================================================================
|
||||
|
||||
6.1 The Software is licensed, not sold. Bartłomiej Janczak retains all right,
|
||||
title, and interest in and to the Software, including all copyrights,
|
||||
moral rights, trade secrets, and any other intellectual property rights,
|
||||
worldwide, except as expressly granted in this License.
|
||||
|
||||
6.2 This License does not transfer to You any ownership interest in or to the
|
||||
Software, any trademarks, trade names, or service marks of the Copyright
|
||||
Holder, or any goodwill associated therewith.
|
||||
|
||||
6.3 You must preserve and reproduce in all permitted copies and portions of
|
||||
the Software:
|
||||
(a) this entire License text, unmodified;
|
||||
(b) the copyright notice: "Copyright (c) 2026 Bartłomiej Janczak."
|
||||
|
||||
6.4 The Software incorporates third-party libraries (including but not limited
|
||||
to Laravel, Filament, and their respective dependencies). Those components
|
||||
are governed exclusively by their own licenses. This License applies only
|
||||
to the original code authored by Bartłomiej Janczak within the
|
||||
janczakb/filament-short-url package and does not modify or supersede the
|
||||
terms of any third-party license.
|
||||
|
||||
6.5 PATENTS. No patent rights are granted except to the minimum extent
|
||||
necessary for You to exercise the copyright permissions expressly granted
|
||||
in Section 2 during Permitted Free Use or under a valid Commercial
|
||||
License. All other patent rights are reserved.
|
||||
|
||||
================================================================================
|
||||
SECTION 7 — TERMINATION
|
||||
================================================================================
|
||||
|
||||
7.1 This License and all rights granted hereunder terminate automatically and
|
||||
immediately, without notice and without judicial action, if You breach any
|
||||
term or condition of this License.
|
||||
|
||||
7.2 Upon termination, You must:
|
||||
(a) immediately cease all use, reproduction, and distribution of the
|
||||
Software;
|
||||
(b) permanently delete all copies, full or partial, of the Software in
|
||||
Your possession or control, including backups, deployment artifacts,
|
||||
and archived copies;
|
||||
(c) upon request, certify in writing to the Copyright Holder that You have
|
||||
complied with Section 7.2(b).
|
||||
|
||||
7.3 Sections 4, 5.4, 6, 7, 8, 9, and 10 survive termination of this License.
|
||||
|
||||
================================================================================
|
||||
SECTION 8 — DISCLAIMER OF WARRANTIES
|
||||
================================================================================
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. THE COPYRIGHT
|
||||
HOLDER DOES NOT WARRANT THAT THE SOFTWARE WILL MEET YOUR REQUIREMENTS, OPERATE
|
||||
WITHOUT INTERRUPTION, OR BE FREE FROM ERRORS OR SECURITY VULNERABILITIES.
|
||||
|
||||
YOU ASSUME THE ENTIRE RISK AS TO THE QUALITY, PERFORMANCE, SECURITY, AND
|
||||
RESULTS OF YOUR USE OF THE SOFTWARE. YOU ARE SOLELY RESPONSIBLE FOR ANY DAMAGE
|
||||
TO YOUR SYSTEMS, DATA, BUSINESS, OR THIRD PARTIES ARISING FROM YOUR USE OR
|
||||
INABILITY TO USE THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
SECTION 9 — LIMITATION OF LIABILITY (LICENSEE CLAIMS AGAINST COPYRIGHT HOLDER)
|
||||
================================================================================
|
||||
|
||||
This Section limits claims that a Licensee (or any user acting under this
|
||||
License) may bring AGAINST the Copyright Holder. It does NOT limit, reduce,
|
||||
or cap any remedy, claim, or enforcement action that the Copyright Holder may
|
||||
bring against You for breach of this License, copyright infringement, or
|
||||
unauthorized use, including without limitation Sections 5.4, 7, and 10.8.
|
||||
|
||||
9.1 EXCLUSION OF DAMAGES. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW,
|
||||
THE COPYRIGHT HOLDER SHALL NOT BE LIABLE TO YOU FOR ANY DAMAGES OF ANY
|
||||
KIND OR NATURE, WHETHER DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
CONSEQUENTIAL, OR PUNITIVE, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS,
|
||||
REVENUE, GOODWILL, DATA, BUSINESS, OR OPPORTUNITY, ARISING OUT OF OR IN
|
||||
CONNECTION WITH THIS LICENSE OR THE SOFTWARE, EVEN IF THE COPYRIGHT
|
||||
HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
9.2 ZERO LIABILITY FOR PROFESSIONAL USE. WHERE PERMITTED FOR PROFESSIONAL OR
|
||||
BUSINESS USE (B2B), THE COPYRIGHT HOLDER SHALL HAVE NO LIABILITY WHATSOEVER
|
||||
TO YOU UNDER OR IN CONNECTION WITH THIS LICENSE.
|
||||
|
||||
9.3 MANDATORY-LAW FLOOR. IF A COMPETENT COURT FINALLY DETERMINES THAT ANY
|
||||
PART OF SECTIONS 9.1 OR 9.2 CANNOT BE ENFORCED FOR A PARTICULAR CLAIM,
|
||||
THE COPYRIGHT HOLDER'S TOTAL AGGREGATE LIABILITY FOR THAT CLAIM AND ALL
|
||||
RELATED CLAIMS ARISING UNDER THIS LICENSE SHALL NOT EXCEED THE GREATER OF:
|
||||
(a) EUR 0 (ZERO EURO); OR
|
||||
(b) THE FEES ACTUALLY PAID BY YOU TO THE COPYRIGHT HOLDER FOR A VALID
|
||||
COMMERCIAL LICENSE FOR THE SOFTWARE IN THE TWELVE (12) MONTHS
|
||||
IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO THE CLAIM;
|
||||
AND IN ALL CASES ONLY TO THE MINIMUM EXTENT REQUIRED BY MANDATORY
|
||||
APPLICABLE LAW. NOTHING IN THIS SECTION 9.3 EXPANDS LIABILITY BEYOND
|
||||
WHAT APPLICABLE LAW REQUIRES.
|
||||
|
||||
9.4 EXCLUSIVE REMEDY. YOUR SOLE AND EXCLUSIVE REMEDY FOR DISSATISFACTION WITH
|
||||
THE SOFTWARE OR ANY BREACH BY THE COPYRIGHT HOLDER OF THIS LICENSE IS TO
|
||||
DISCONTINUE USE OF THE SOFTWARE AND DELETE ALL COPIES IN YOUR POSSESSION
|
||||
OR CONTROL.
|
||||
|
||||
9.5 NO LIMITATION ON COPYRIGHT HOLDER ENFORCEMENT. For clarity: if You breach
|
||||
this License, the Copyright Holder may pursue all remedies available under
|
||||
this License and applicable law without regard to this Section 9. A person
|
||||
who infringes or misuses the Software is not a beneficiary of this
|
||||
limitation and may owe the Copyright Holder damages, injunctive relief,
|
||||
disgorgement of profits, and other remedies far exceeding any amount
|
||||
referenced in Section 9.3.
|
||||
|
||||
================================================================================
|
||||
SECTION 10 — GENERAL PROVISIONS
|
||||
================================================================================
|
||||
|
||||
10.1 GOVERNING LAW. This License is governed by and construed in accordance
|
||||
with the substantive law of Poland, in particular the Act of 4 February
|
||||
1994 on Copyright and Related Rights (Dz.U. 2022 poz. 2509, as amended),
|
||||
without regard to its conflict-of-laws rules.
|
||||
|
||||
10.2 JURISDICTION. Any dispute arising out of or in connection with this
|
||||
License shall be submitted to the exclusive jurisdiction of the competent
|
||||
courts in Warsaw, Poland.
|
||||
|
||||
10.3 SEVERABILITY. If any provision of this License is held invalid, illegal,
|
||||
or unenforceable by a court of competent jurisdiction, that provision
|
||||
shall be modified to the minimum extent necessary to make it enforceable,
|
||||
or if modification is not possible, severed from this License, while the
|
||||
remaining provisions continue in full force and effect.
|
||||
|
||||
10.4 ENTIRE AGREEMENT. This License constitutes the entire agreement between
|
||||
the parties with respect to the Software and supersedes all prior or
|
||||
contemporaneous agreements, representations, or understandings relating
|
||||
to the Software.
|
||||
|
||||
10.5 WAIVER. The Copyright Holder's failure to enforce any provision of this
|
||||
License does not constitute a waiver of the right to enforce that
|
||||
provision in the future.
|
||||
|
||||
10.6 MODIFICATIONS TO THIS LICENSE. The Copyright Holder reserves the right to
|
||||
publish revised versions of this License. Each version will be identified
|
||||
by a version number. The version under which You obtained the Software
|
||||
governs Your use unless the Copyright Holder expressly states otherwise.
|
||||
This is version 1.3, effective 8 June 2026. Version 1.3 supersedes
|
||||
version 1.2 for copies obtained on or after its effective date.
|
||||
|
||||
10.7 LANGUAGE. This License is written in English. Any translation is provided
|
||||
for convenience only; in the event of a conflict the English text prevails.
|
||||
|
||||
10.8 AUDIT. Upon reasonable prior written notice, the Copyright Holder may
|
||||
request written confirmation that Your use remains within Permitted Free
|
||||
Use, including monthly Qualified Event volumes. You must respond within
|
||||
thirty (30) calendar days with good-faith information. Refusal to respond
|
||||
or material misrepresentation constitutes a breach of this License.
|
||||
|
||||
================================================================================
|
||||
END OF LICENSE
|
||||
================================================================================
|
||||
|
||||
98
art/diagrams/01-system-overview.svg
Normal file
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" role="img" aria-label="System overview">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="600" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="520" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">System overview</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Filament panel, public routes, core services, persistence</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="100" width="272" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
|
||||
<text x="72" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Filament admin</text>
|
||||
<text x="72" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Panel layer</text>
|
||||
<text x="72" y="188" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">ShortUrlResource</text>
|
||||
<text x="72" y="210" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Settings, domains, QR</text>
|
||||
<text x="72" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Analytics and live feed</text>
|
||||
<rect x="72" y="258" width="170" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="157.0" y="274.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">scope_links_to_user</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="344" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="344" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="344" y="100" width="272" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
|
||||
<text x="368" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Public HTTP</text>
|
||||
<text x="368" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Edge of Laravel app</text>
|
||||
<text x="368" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /s/[key]</text>
|
||||
<text x="368" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /s-auth/[key]</text>
|
||||
<text x="368" y="232" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /api/short-url/*</text>
|
||||
<text x="368" y="254" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">/[key] on custom domain</text>
|
||||
<rect x="368" y="278" width="92" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#38bdf8" stroke-opacity="0.35"/><text x="414.0" y="293.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">STATELESS</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="640" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="640" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="640" y="100" width="272" height="3" rx="1.5" fill="#2dd4bf" fill-opacity="0.85"/>
|
||||
<text x="664" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Package core</text>
|
||||
<text x="664" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Runtime services</text>
|
||||
<text x="664" y="188" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">RedirectHandler</text>
|
||||
<text x="664" y="210" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">TrackShortUrlVisitJob</text>
|
||||
<text x="664" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">SendWebhookJob (HMAC)</text>
|
||||
<text x="664" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">HasStats and rollups</text>
|
||||
</g><line x1="184" y1="320" x2="184" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="480" y1="320" x2="480" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="776" y1="320" x2="776" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="120" y="360" width="720" height="130" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="120" y="360" width="720" height="130" rx="28" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="120" y="360" width="720" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
|
||||
<text x="480" y="396" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Persistence layer</text>
|
||||
<text x="480" y="422" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#94a3b8">short_urls, visits, daily_stats, cache, optional Redis buffer</text>
|
||||
<rect x="200" y="448" width="130" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="265.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">SQL DB</text>
|
||||
<rect x="350" y="448" width="110" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="405.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Redis</text>
|
||||
<rect x="480" y="448" width="130" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="545.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Safe Browsing</text>
|
||||
<rect x="630" y="448" width="110" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="685.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Link cache</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="512" width="864" height="56" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="512" width="864" height="56" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<text x="480" y="546" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e9d5ff">Self-hosted Laravel plugin, not edge SaaS</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
109
art/diagrams/02-redirect-lifecycle.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 680" role="img" aria-label="Redirect lifecycle">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="680" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="600" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Redirect lifecycle</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">GET /s/[key] from click to destination</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="280" y="96" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="280" y="96" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="280" y="96" width="400" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
|
||||
<text x="480" y="120" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Visitor click</text>
|
||||
<text x="480" y="138" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Start of request</text>
|
||||
</g><line x1="480" y1="150" x2="480" y2="168" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="280" y="168" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="280" y="168" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="280" y="168" width="400" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
|
||||
<text x="480" y="192" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Throttle only</text>
|
||||
<text x="480" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">No web middleware, no session cookie</text>
|
||||
</g><line x1="480" y1="222" x2="480" y2="240" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="280" y="240" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="280" y="240" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="280" y="240" width="400" height="3" rx="1.5" fill="#a78bfa" fill-opacity="0.85"/>
|
||||
<text x="480" y="264" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">findByKey(host)</text>
|
||||
<text x="480" y="282" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Cache hit or single DB resolve</text>
|
||||
</g><line x1="480" y1="294" x2="480" y2="312" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="280" y="312" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="280" y="312" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="280" y="312" width="400" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
|
||||
<text x="480" y="336" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Safe Browsing cache</text>
|
||||
<text x="480" y="354" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">isSafeCached(destination) when enabled</text>
|
||||
</g><line x1="480" y1="366" x2="480" y2="384" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="280" y="384" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="280" y="384" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="280" y="384" width="400" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="480" y="408" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">max_visits lock</text>
|
||||
<text x="480" y="426" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">lockForUpdate plus isActive()</text>
|
||||
</g><line x1="480" y1="456" x2="480" y2="476" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><polygon points="480,476 410,514 550,514" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/><text x="480" y="504" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="600">Response path</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="528" width="272" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
|
||||
<text x="184" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Password</text>
|
||||
<text x="184" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">/s-auth/[key] + web</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="344" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="344" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="344" y="528" width="272" height="3" rx="1.5" fill="#facc15" fill-opacity="0.85"/>
|
||||
<text x="480" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Interstitial</text>
|
||||
<text x="480" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">warning, pixels, cloak, OG</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="640" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="640" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="640" y="528" width="272" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
|
||||
<text x="776" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Simple 302</text>
|
||||
<text x="776" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">TrackJob then redirect</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="120" y="636" width="720" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="120" y="636" width="720" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
|
||||
<text x="480" y="664" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#cbd5e1">TrackShortUrlVisitJob: GeoIP, VPN, visit, webhook, GA4 (sync default or async queue)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.8 KiB |
78
art/diagrams/03-stateless-vs-stateful.svg
Normal file
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 460" role="img" aria-label="Stateless vs stateful">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="460" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="400" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Stateless vs stateful routes</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Lean clicks vs session-backed password unlock</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="96" width="416" height="280" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="96" width="416" height="280" rx="28" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="96" width="416" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="76" y="132" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Every redirect click</text>
|
||||
<rect x="76" y="148" width="88" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="120.0" y="163.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">STATELESS</text>
|
||||
<text x="76" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /s/[key]</text>
|
||||
<text x="76" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /[key] on custom domain</text>
|
||||
<text x="76" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Middleware: throttle:120,1</text>
|
||||
<text x="76" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">No cookies, no CSRF</text>
|
||||
<rect x="128" y="284" width="200" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="228.0" y="300.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Fixed in v5.2</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="496" y="96" width="416" height="280" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="496" y="96" width="416" height="280" rx="28" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="496" y="96" width="416" height="3" rx="1.5" fill="#60a5fa" fill-opacity="0.85"/>
|
||||
<text x="524" y="132" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Password unlock only</text>
|
||||
<rect x="524" y="148" width="88" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="568.0" y="163.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">SESSION</text>
|
||||
<text x="524" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /s-auth/[key]</text>
|
||||
<text x="524" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /auth/[key] on custom domain</text>
|
||||
<text x="524" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Middleware: web + throttle</text>
|
||||
<text x="524" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Unlock remembered per session</text>
|
||||
<rect x="576" y="284" width="200" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="676.0" y="300.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">By design</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="396" width="864" height="44" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="396" width="864" height="44" rx="16" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="396" width="864" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
|
||||
<text x="480" y="424" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fecaca">Old audit: web on /s/[key] sent cookies on every click. Republish config if middleware still includes web.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
80
art/diagrams/04-custom-domain-routing.svg
Normal file
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" role="img" aria-label="Custom domain routing">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="480" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Custom domains and domain_scope_id</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Same slug on different domains without collision</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="64" y="96" width="380" height="150" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="64" y="96" width="380" height="150" rx="24" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="64" y="96" width="380" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
|
||||
<text x="254" y="132" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Default app domain</text>
|
||||
<text x="254" y="160" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#ddd6fe">app.test/s/promo</text>
|
||||
<rect x="154" y="178" width="200" height="26" rx="13" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="254.0" y="195.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">domain_scope_id = 0</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="516" y="96" width="380" height="150" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="516" y="96" width="380" height="150" rx="24" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="516" y="96" width="380" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
|
||||
<text x="706" y="132" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Verified custom domain</text>
|
||||
<text x="706" y="160" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#bae6fd">links.brand.com/promo</text>
|
||||
<rect x="586" y="178" width="240" height="26" rx="13" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="706.0" y="195.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">scope = custom_domain_id</text>
|
||||
</g><line x1="254" y1="246" x2="254" y2="276" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="706" y1="246" x2="706" y2="276" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="180" y="276" width="600" height="62" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="180" y="276" width="600" height="62" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="180" y="276" width="600" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="480" y="304" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#a7f3d0" font-weight="700">Composite unique: (url_key, domain_scope_id)</text>
|
||||
<text x="480" y="324" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#94a3b8">GET /links/exists?url_key=promo&custom_domain_id=3</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="80" y="360" width="800" height="120" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="80" y="360" width="800" height="120" rx="24" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="80" y="360" width="800" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
|
||||
<text x="480" y="392" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#fed7aa" font-weight="700">Ownership enforcement</text>
|
||||
<text x="480" y="418" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Filament domains scoped by user_id</text>
|
||||
<text x="480" y="442" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">API: CustomDomainValidator blocks foreign domain IDs</text>
|
||||
<rect x="320" y="458" width="320" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="480.0" y="474.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Fixed: exists scope + ownership (v5.2)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
85
art/diagrams/05-targeting-and-ab.svg
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 460" role="img" aria-label="Targeting and A/B">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="460" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="400" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Smart targeting and A/B split</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">RedirectUrlResolver evaluates rules top to bottom</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="360" y="92" width="240" height="46" rx="23" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="360" y="92" width="240" height="46" rx="23" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="360" y="92" width="240" height="3" rx="1.5" fill="#a78bfa" fill-opacity="0.85"/>
|
||||
<text x="480" y="122" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="13" fill="#e2e8f0" font-weight="600">GET /s/[key]</text>
|
||||
</g><line x1="480" y1="138" x2="480" y2="158" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="300" y="158" width="360" height="56" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="300" y="158" width="360" height="56" rx="18" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="300" y="158" width="360" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
|
||||
<text x="480" y="184" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">RedirectUrlResolver</text>
|
||||
<text x="480" y="204" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">UA, Geo, language, rules</text>
|
||||
</g><line x1="480" y1="214" x2="480" y2="238" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><polygon points="480,238 400,272 560,272" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/><text x="480" y="262" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="600">First matching rule?</text><line x1="400" y1="272" x2="160" y2="310" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="560" y1="272" x2="800" y2="310" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="310" width="272" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
|
||||
<text x="184" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Rule matched</text>
|
||||
<text x="184" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Use rule URL</text>
|
||||
<text x="184" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Nested A/B in rule</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="344" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="344" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="344" y="310" width="272" height="3" rx="1.5" fill="#facc15" fill-opacity="0.85"/>
|
||||
<text x="480" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Root A/B</text>
|
||||
<text x="480" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Weighted 2-5 URLs</text>
|
||||
<text x="480" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Variant on visit row</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="640" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="640" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="640" y="310" width="272" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
|
||||
<text x="776" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">No match</text>
|
||||
<text x="776" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">destination_url</text>
|
||||
<text x="776" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">UTM merge + query forward</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
83
art/diagrams/06-webhooks-and-security.svg
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 500" role="img" aria-label="Webhooks and HMAC">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="500" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="440" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Webhooks and HMAC signing</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Global events, per-link URL override, SSRF-safe dispatch</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="60" y="96" width="360" height="140" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="60" y="96" width="360" height="140" rx="22" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="60" y="96" width="360" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
|
||||
<text x="240" y="128" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Global webhook</text>
|
||||
<text x="240" y="152" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Settings: URL, enabled, events</text>
|
||||
<text x="240" y="174" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#e2e8f0">visited | created | expired | limit</text>
|
||||
<text x="240" y="196" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#ddd6fe">Signing secret required (v5.2)</text>
|
||||
<rect x="120" y="208" width="240" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="240.0" y="223.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Events are global, not per-link</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="540" y="96" width="360" height="140" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="540" y="96" width="360" height="140" rx="22" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="540" y="96" width="360" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
|
||||
<text x="720" y="128" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Per-link webhook URL</text>
|
||||
<text x="720" y="152" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">short_urls.webhook_url override</text>
|
||||
<text x="720" y="174" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">SSRF re-check in SendWebhookJob</text>
|
||||
<text x="720" y="196" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#e2e8f0">allow_redirects=false</text>
|
||||
</g><line x1="480" y1="236" x2="480" y2="262" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="200" y="262" width="560" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="200" y="262" width="560" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="200" y="262" width="560" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="480" y="286" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#e2e8f0" font-weight="700">SendWebhookJob</text>
|
||||
<text x="480" y="304" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#94a3b8">Header: X-Short-Url-Signature sha256=... (HMAC body)</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="80" y="336" width="800" height="120" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="80" y="336" width="800" height="120" rx="24" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="80" y="336" width="800" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
|
||||
<text x="480" y="368" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#fed7aa" font-weight="700">Audit fixes (v5.2)</text>
|
||||
<text x="480" y="394" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Before: optional secret allowed unsigned webhooks</text>
|
||||
<text x="480" y="418" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">After: Settings rejects enable without secret</text>
|
||||
<text x="480" y="442" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Safe Browsing on save + redirect | lock_url_key server-side</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
116
art/diagrams/07-stats-and-queue.svg
Normal file
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" role="img" aria-label="Stats and queue">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="480" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Stats pipeline and queue modes</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Visits, aggregation cron, panel vs API stats gap</text>
|
||||
<text x="480" y="98" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Visit tracking</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="60" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="60" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="60" y="112" width="160" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
|
||||
<text x="140" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">Redirect</text>
|
||||
</g><line x1="220" y1="136" x2="280" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="280" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="280" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="280" y="112" width="160" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
|
||||
<text x="360" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">TrackJob</text>
|
||||
</g><line x1="440" y1="136" x2="540" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="540" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="540" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="540" y="112" width="160" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
|
||||
<text x="620" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">visits table</text>
|
||||
</g><line x1="700" y1="136" x2="740" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="740" y="112" width="120" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="740" y="112" width="120" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="740" y="112" width="120" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
|
||||
<text x="800" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">Buffer</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="120" y="180" width="340" height="110" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="120" y="180" width="340" height="110" rx="22" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="120" y="180" width="340" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="290" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#6ee7b7" font-weight="700">sync (default)</text>
|
||||
<text x="290" y="234" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Job runs inside redirect</text>
|
||||
<text x="290" y="256" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Works without queue worker</text>
|
||||
<text x="290" y="278" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">GeoIP, webhook, GA4 add latency</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="500" y="180" width="340" height="110" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="500" y="180" width="340" height="110" rx="22" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="500" y="180" width="340" height="3" rx="1.5" fill="#60a5fa" fill-opacity="0.85"/>
|
||||
<text x="670" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#93c5fd" font-weight="700">redis / database</text>
|
||||
<text x="670" y="234" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">302 sent first</text>
|
||||
<text x="670" y="256" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Requires queue:work</text>
|
||||
<text x="670" y="278" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">Settings - General</text>
|
||||
</g><text x="480" y="318" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#e2e8f0" font-weight="700">Aggregation (schedule:run)</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="140" y="332" width="200" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="140" y="332" width="200" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="140" y="332" width="200" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
|
||||
<text x="240" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Raw visits 90d</text>
|
||||
</g><line x1="340" y1="354" x2="400" y2="354" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="400" y="332" width="240" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="400" y="332" width="240" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="400" y="332" width="240" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
|
||||
<text x="520" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">AggregateAndPrune</text>
|
||||
</g><line x1="640" y1="354" x2="700" y2="354" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="700" y="332" width="200" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="700" y="332" width="200" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="700" y="332" width="200" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
|
||||
<text x="800" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">daily_stats</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="80" y="400" width="800" height="90" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="80" y="400" width="800" height="90" rx="24" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="80" y="400" width="800" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
|
||||
<text x="480" y="430" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Filament: FilteredStatsCollector + cross-dimensional rollups</text>
|
||||
<text x="480" y="454" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">API stats: date_from / date_to only (cross-filters = open gap)</text>
|
||||
<text x="480" y="476" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">Long-range reports use rollups after raw prune</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
143
art/diagrams/08-audit-trust-summary.svg
Normal file
@@ -0,0 +1,143 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 560" role="img" aria-label="Audit trust summary">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="560" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="500" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">v5.2 audit trust summary</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Promise vs old reality vs current status</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="92" width="864" height="36" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="92" width="864" height="36" rx="14" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="92" width="864" height="3" rx="1.5" fill="#ffffff" fill-opacity="0.85"/>
|
||||
<text x="180" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Audit promise</text>
|
||||
<text x="440" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Old reality</text>
|
||||
<text x="730" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">v5.2 status</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="140" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="140" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="140" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Sub-15ms / stateless</text>
|
||||
<text x="330" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">web on /s/[key]</text>
|
||||
<text x="600" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: throttle only</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="180" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="180" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="180" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Multi-tenant panel</text>
|
||||
<text x="330" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">All admins see all links</text>
|
||||
<text x="600" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: LinkUserScope</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="220" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="220" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="220" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">API exists</text>
|
||||
<text x="330" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Ignored domain scope</text>
|
||||
<text x="600" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: domain_scope_id</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="260" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="260" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="260" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Domain ownership</text>
|
||||
<text x="330" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Foreign domain ID</text>
|
||||
<text x="600" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: CustomDomainValidator</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="300" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="300" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="300" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Safe Browsing</text>
|
||||
<text x="330" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Save only</text>
|
||||
<text x="600" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: redirect cache</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="340" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="340" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="340" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Webhook HMAC</text>
|
||||
<text x="330" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Optional secret</text>
|
||||
<text x="600" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: required when on</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="380" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="380" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="380" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">lock_url_key</text>
|
||||
<text x="330" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">UI disabled only</text>
|
||||
<text x="600" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: LockedUrlKeyGuard</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="420" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="420" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="420" width="864" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
|
||||
<text x="64" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Per-link webhook events</text>
|
||||
<text x="330" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Documented per-link</text>
|
||||
<text x="600" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fbbf24" font-weight="600">OPEN: global list</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="460" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="460" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="460" width="864" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
|
||||
<text x="64" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Default queue async</text>
|
||||
<text x="330" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">README vs sync</text>
|
||||
<text x="600" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fbbf24" font-weight="600">BY DESIGN: sync default</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="500" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="500" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="500" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
|
||||
<text x="64" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Dub parity table</text>
|
||||
<text x="330" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Overstated</text>
|
||||
<text x="600" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: scoped compare</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="548" width="864" height="72" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="48" y="548" width="864" height="72" rx="20" fill="url(#shine)" pointer-events="none"/>
|
||||
<rect x="48" y="548" width="864" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
|
||||
<text x="480" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e9d5ff" font-weight="600">Product boundary: Laravel Filament plugin, not Dub clone</text>
|
||||
<text x="480" y="598" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Open: OpenAPI, API stats filters, workspaces, conversion, load tests</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
353
art/diagrams/generate-glass-diagrams.py
Normal file
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate liquid-glass architecture SVG diagrams for README."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path(__file__).parent
|
||||
|
||||
GLASS_HEAD = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 {height}" role="img" aria-label="{label}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b"/>
|
||||
<stop offset="45%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#164e63"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
|
||||
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
|
||||
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="80"/>
|
||||
</filter>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="{height}" fill="url(#bg)"/>
|
||||
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
|
||||
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
|
||||
<ellipse cx="480" cy="{orb_y}" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
|
||||
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">{title}</text>
|
||||
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">{subtitle}</text>
|
||||
"""
|
||||
|
||||
GLASS_CARD = """
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
|
||||
<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" fill="url(#shine)" pointer-events="none"/>
|
||||
{accent}{content}
|
||||
</g>"""
|
||||
|
||||
ACCENT_TOP = '<rect x="{x}" y="{y}" width="{w}" height="3" rx="1.5" fill="{color}" fill-opacity="0.85"/>\n '
|
||||
|
||||
FOOT = """
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
def card(x, y, w, h, content, rx=26, accent=None):
|
||||
accent_html = ""
|
||||
if accent:
|
||||
accent_html = ACCENT_TOP.format(x=x, y=y, w=w, color=accent)
|
||||
return GLASS_CARD.format(x=x, y=y, w=w, h=h, rx=rx, accent=accent_html, content=content)
|
||||
|
||||
|
||||
def t(x, y, text, size=12, weight="400", fill="#e2e8f0", anchor="start", mono=False):
|
||||
fam = "ui-monospace,Menlo,monospace" if mono else "system-ui,-apple-system,Segoe UI,sans-serif"
|
||||
w = f' font-weight="{weight}"' if weight != "400" else ""
|
||||
return f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-family="{fam}" font-size="{size}" fill="{fill}"{w}>{text}</text>'
|
||||
|
||||
|
||||
def line(x1, y1, x2, y2):
|
||||
return f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>'
|
||||
|
||||
|
||||
def pill(x, y, w, h, text, stroke="#ffffff"):
|
||||
return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{h//2}" fill="#ffffff" fill-opacity="0.08" stroke="{stroke}" stroke-opacity="0.35"/><text x="{x+w/2}" y="{y+h/2+4}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">{text}</text>'
|
||||
|
||||
|
||||
def diagram_01():
|
||||
h, oy = 600, 520
|
||||
body = GLASS_HEAD.format(height=h, label="System overview", title="System overview", subtitle="Filament panel, public routes, core services, persistence", orb_y=oy)
|
||||
body += card(48, 100, 272, 220, "\n ".join([
|
||||
t(72, 136, "Filament admin", 16, "700"),
|
||||
t(72, 158, "Panel layer", 11, fill="#94a3b8"),
|
||||
t(72, 188, "ShortUrlResource", 12),
|
||||
t(72, 210, "Settings, domains, QR", 12),
|
||||
t(72, 232, "Analytics and live feed", 12),
|
||||
pill(72, 258, 170, 24, "scope_links_to_user"),
|
||||
]), accent="#c084fc")
|
||||
body += card(344, 100, 272, 220, "\n ".join([
|
||||
t(368, 136, "Public HTTP", 16, "700"),
|
||||
t(368, 158, "Edge of Laravel app", 11, fill="#94a3b8"),
|
||||
t(368, 188, "GET /s/[key]", 11, mono=True, fill="#bae6fd"),
|
||||
t(368, 210, "GET /s-auth/[key]", 11, mono=True, fill="#bae6fd"),
|
||||
t(368, 232, "GET /api/short-url/*", 11, mono=True, fill="#bae6fd"),
|
||||
t(368, 254, "/[key] on custom domain", 11, mono=True, fill="#bae6fd"),
|
||||
pill(368, 278, 92, 22, "STATELESS", stroke="#38bdf8"),
|
||||
]), accent="#38bdf8")
|
||||
body += card(640, 100, 272, 220, "\n ".join([
|
||||
t(664, 136, "Package core", 16, "700"),
|
||||
t(664, 158, "Runtime services", 11, fill="#94a3b8"),
|
||||
t(664, 188, "RedirectHandler", 12),
|
||||
t(664, 210, "TrackShortUrlVisitJob", 12),
|
||||
t(664, 232, "SendWebhookJob (HMAC)", 12),
|
||||
t(664, 254, "HasStats and rollups", 12),
|
||||
]), accent="#2dd4bf")
|
||||
body += line(184, 320, 184, 352) + line(480, 320, 480, 352) + line(776, 320, 776, 352)
|
||||
body += card(120, 360, 720, 130, "\n ".join([
|
||||
t(480, 396, "Persistence layer", 17, "700", anchor="middle"),
|
||||
t(480, 422, "short_urls, visits, daily_stats, cache, optional Redis buffer", 12, anchor="middle", fill="#94a3b8"),
|
||||
pill(200, 448, 130, 24, "SQL DB"),
|
||||
pill(350, 448, 110, 24, "Redis"),
|
||||
pill(480, 448, 130, 24, "Safe Browsing"),
|
||||
pill(630, 448, 110, 24, "Link cache"),
|
||||
]), accent="#fb923c", rx=28)
|
||||
body += card(48, 512, 864, 56, t(480, 546, "Self-hosted Laravel plugin, not edge SaaS", 13, anchor="middle", fill="#e9d5ff"), rx=20)
|
||||
return body + FOOT
|
||||
|
||||
|
||||
def diagram_02():
|
||||
h, oy = 680, 600
|
||||
body = GLASS_HEAD.format(height=h, label="Redirect lifecycle", title="Redirect lifecycle", subtitle="GET /s/[key] from click to destination", orb_y=oy)
|
||||
steps = [
|
||||
("Visitor click", "Start of request", "#818cf8"),
|
||||
("Throttle only", "No web middleware, no session cookie", "#fbbf24"),
|
||||
("findByKey(host)", "Cache hit or single DB resolve", "#a78bfa"),
|
||||
("Safe Browsing cache", "isSafeCached(destination) when enabled", "#f87171"),
|
||||
("max_visits lock", "lockForUpdate plus isActive()", "#34d399"),
|
||||
]
|
||||
y = 96
|
||||
for i, (title, sub, color) in enumerate(steps):
|
||||
body += card(280, y, 400, 54, "\n ".join([
|
||||
t(480, y + 24, title, 13, "600", anchor="middle"),
|
||||
t(480, y + 42, sub, 11, anchor="middle", fill="#94a3b8"),
|
||||
]), accent=color, rx=18)
|
||||
if i < len(steps) - 1:
|
||||
body += line(480, y + 54, 480, y + 72)
|
||||
y += 72
|
||||
body += line(480, y, 480, y + 20)
|
||||
body += '<polygon points="480,{0} 410,{1} 550,{1}" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/>'.format(y + 20, y + 58)
|
||||
body += t(480, y + 48, "Response path", 12, "600", anchor="middle")
|
||||
y2 = y + 72
|
||||
branches = [
|
||||
(48, "Password", "/s-auth/[key] + web", "#fb923c"),
|
||||
(344, "Interstitial", "warning, pixels, cloak, OG", "#facc15"),
|
||||
(640, "Simple 302", "TrackJob then redirect", "#4ade80"),
|
||||
]
|
||||
for x, title, sub, color in branches:
|
||||
body += card(x, y2, 272, 88, "\n ".join([
|
||||
t(x + 136, y2 + 28, title, 13, "700", anchor="middle"),
|
||||
t(x + 136, y2 + 48, sub, 10, anchor="middle", fill="#94a3b8"),
|
||||
]), accent=color, rx=20)
|
||||
body += card(120, y2 + 108, 720, 48, t(480, y2 + 136, "TrackShortUrlVisitJob: GeoIP, VPN, visit, webhook, GA4 (sync default or async queue)", 11, anchor="middle", fill="#cbd5e1"), rx=16)
|
||||
return body + FOOT
|
||||
|
||||
|
||||
def diagram_03():
|
||||
h, oy = 460, 400
|
||||
body = GLASS_HEAD.format(height=h, label="Stateless vs stateful", title="Stateless vs stateful routes", subtitle="Lean clicks vs session-backed password unlock", orb_y=oy)
|
||||
for x, title, badge, lines, accent in [
|
||||
(48, "Every redirect click", "STATELESS", ["GET /s/[key]", "GET /[key] on custom domain", "Middleware: throttle:120,1", "No cookies, no CSRF"], "#34d399"),
|
||||
(496, "Password unlock only", "SESSION", ["GET /s-auth/[key]", "GET /auth/[key] on custom domain", "Middleware: web + throttle", "Unlock remembered per session"], "#60a5fa"),
|
||||
]:
|
||||
content = [t(x + 28, 132, title, 17, "700"), pill(x + 28, 148, 88, 22, badge)]
|
||||
cy = 188
|
||||
for ln in lines:
|
||||
content.append(t(x + 28, cy, ln, 12, mono=("/" in ln)))
|
||||
cy += 22
|
||||
content.append(pill(x + 80, cy + 8, 200, 24, "Fixed in v5.2" if x == 48 else "By design"))
|
||||
body += card(x, 96, 416, 280, "\n ".join(content), accent=accent, rx=28)
|
||||
body += card(48, 396, 864, 44, t(480, 424, "Old audit: web on /s/[key] sent cookies on every click. Republish config if middleware still includes web.", 11, anchor="middle", fill="#fecaca"), rx=16, accent="#f87171")
|
||||
return body + FOOT
|
||||
|
||||
|
||||
def diagram_04():
|
||||
h, oy = 540, 480
|
||||
body = GLASS_HEAD.format(height=h, label="Custom domain routing", title="Custom domains and domain_scope_id", subtitle="Same slug on different domains without collision", orb_y=oy)
|
||||
body += card(64, 96, 380, 150, "\n ".join([
|
||||
t(254, 132, "Default app domain", 16, "700", anchor="middle"),
|
||||
t(254, 160, "app.test/s/promo", 12, anchor="middle", mono=True, fill="#ddd6fe"),
|
||||
pill(154, 178, 200, 26, "domain_scope_id = 0"),
|
||||
]), accent="#c084fc", rx=24)
|
||||
body += card(516, 96, 380, 150, "\n ".join([
|
||||
t(706, 132, "Verified custom domain", 16, "700", anchor="middle"),
|
||||
t(706, 160, "links.brand.com/promo", 12, anchor="middle", mono=True, fill="#bae6fd"),
|
||||
pill(586, 178, 240, 26, "scope = custom_domain_id"),
|
||||
]), accent="#38bdf8", rx=24)
|
||||
body += line(254, 246, 254, 276) + line(706, 246, 706, 276)
|
||||
body += card(180, 276, 600, 62, "\n ".join([
|
||||
t(480, 304, "Composite unique: (url_key, domain_scope_id)", 13, "700", anchor="middle", fill="#a7f3d0"),
|
||||
t(480, 324, "GET /links/exists?url_key=promo&custom_domain_id=3", 11, anchor="middle", mono=True, fill="#94a3b8"),
|
||||
]), accent="#34d399", rx=20)
|
||||
body += card(80, 360, 800, 120, "\n ".join([
|
||||
t(480, 392, "Ownership enforcement", 16, "700", anchor="middle", fill="#fed7aa"),
|
||||
t(480, 418, "Filament domains scoped by user_id", 12, anchor="middle"),
|
||||
t(480, 442, "API: CustomDomainValidator blocks foreign domain IDs", 12, anchor="middle"),
|
||||
pill(320, 458, 320, 24, "Fixed: exists scope + ownership (v5.2)"),
|
||||
]), accent="#fb923c", rx=24)
|
||||
return body + FOOT
|
||||
|
||||
|
||||
def diagram_05():
|
||||
h, oy = 460, 400
|
||||
body = GLASS_HEAD.format(height=h, label="Targeting and A/B", title="Smart targeting and A/B split", subtitle="RedirectUrlResolver evaluates rules top to bottom", orb_y=oy)
|
||||
body += card(360, 92, 240, 46, t(480, 122, "GET /s/[key]", 13, "600", anchor="middle", mono=True), rx=23, accent="#a78bfa")
|
||||
body += line(480, 138, 480, 158)
|
||||
body += card(300, 158, 360, 56, "\n ".join([
|
||||
t(480, 184, "RedirectUrlResolver", 13, "600", anchor="middle"),
|
||||
t(480, 204, "UA, Geo, language, rules", 11, anchor="middle", fill="#94a3b8"),
|
||||
]), rx=18, accent="#38bdf8")
|
||||
body += line(480, 214, 480, 238)
|
||||
body += '<polygon points="480,238 400,272 560,272" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/>'
|
||||
body += t(480, 262, "First matching rule?", 12, "600", anchor="middle")
|
||||
body += line(400, 272, 160, 310) + line(560, 272, 800, 310)
|
||||
for x, title, lines, color in [
|
||||
(48, "Rule matched", ["Use rule URL", "Nested A/B in rule"], "#4ade80"),
|
||||
(344, "Root A/B", ["Weighted 2-5 URLs", "Variant on visit row"], "#facc15"),
|
||||
(640, "No match", ["destination_url", "UTM merge + query forward"], "#f87171"),
|
||||
]:
|
||||
body += card(x, 310, 272, 100, "\n ".join([t(x + 136, 338, title, 12, "700", anchor="middle")] + [t(x + 136, 358 + i * 18, ln, 10, anchor="middle", fill="#94a3b8") for i, ln in enumerate(lines)]), accent=color, rx=20)
|
||||
return body + FOOT
|
||||
|
||||
|
||||
def diagram_06():
|
||||
h, oy = 500, 440
|
||||
body = GLASS_HEAD.format(height=h, label="Webhooks and HMAC", title="Webhooks and HMAC signing", subtitle="Global events, per-link URL override, SSRF-safe dispatch", orb_y=oy)
|
||||
body += card(60, 96, 360, 140, "\n ".join([
|
||||
t(240, 128, "Global webhook", 15, "700", anchor="middle"),
|
||||
t(240, 152, "Settings: URL, enabled, events", 11, anchor="middle", fill="#94a3b8"),
|
||||
t(240, 174, "visited | created | expired | limit", 10, anchor="middle", mono=True),
|
||||
t(240, 196, "Signing secret required (v5.2)", 11, anchor="middle", fill="#ddd6fe"),
|
||||
pill(120, 208, 240, 22, "Events are global, not per-link"),
|
||||
]), accent="#c084fc", rx=22)
|
||||
body += card(540, 96, 360, 140, "\n ".join([
|
||||
t(720, 128, "Per-link webhook URL", 15, "700", anchor="middle"),
|
||||
t(720, 152, "short_urls.webhook_url override", 11, anchor="middle", fill="#94a3b8"),
|
||||
t(720, 174, "SSRF re-check in SendWebhookJob", 11, anchor="middle"),
|
||||
t(720, 196, "allow_redirects=false", 10, anchor="middle", mono=True),
|
||||
]), accent="#38bdf8", rx=22)
|
||||
body += line(480, 236, 480, 262)
|
||||
body += card(200, 262, 560, 54, "\n ".join([
|
||||
t(480, 286, "SendWebhookJob", 14, "700", anchor="middle"),
|
||||
t(480, 304, "Header: X-Short-Url-Signature sha256=... (HMAC body)", 10, anchor="middle", mono=True, fill="#94a3b8"),
|
||||
]), accent="#34d399", rx=18)
|
||||
body += card(80, 336, 800, 120, "\n ".join([
|
||||
t(480, 368, "Audit fixes (v5.2)", 15, "700", anchor="middle", fill="#fed7aa"),
|
||||
t(480, 394, "Before: optional secret allowed unsigned webhooks", 12, anchor="middle"),
|
||||
t(480, 418, "After: Settings rejects enable without secret", 12, anchor="middle"),
|
||||
t(480, 442, "Safe Browsing on save + redirect | lock_url_key server-side", 11, anchor="middle", fill="#94a3b8"),
|
||||
]), accent="#fb923c", rx=24)
|
||||
return body + FOOT
|
||||
|
||||
|
||||
def diagram_07():
|
||||
h, oy = 540, 480
|
||||
body = GLASS_HEAD.format(height=h, label="Stats and queue", title="Stats pipeline and queue modes", subtitle="Visits, aggregation cron, panel vs API stats gap", orb_y=oy)
|
||||
body += t(480, 98, "Visit tracking", 15, "700", anchor="middle")
|
||||
boxes = [(60, "Redirect"), (280, "TrackJob"), (540, "visits table"), (740, "Buffer")]
|
||||
for i, (x, label) in enumerate(boxes):
|
||||
body += card(x, 112, 160 if x < 740 else 120, 48, t(x + (80 if x < 740 else 60), 142, label, 11, "600", anchor="middle"), rx=16, accent="#818cf8" if i == 1 else "#38bdf8")
|
||||
if i < len(boxes) - 1:
|
||||
nx = boxes[i + 1][0]
|
||||
body += line(x + (160 if x < 740 else 120), 136, nx, 136)
|
||||
body += card(120, 180, 340, 110, "\n ".join([
|
||||
t(290, 210, "sync (default)", 15, "700", anchor="middle", fill="#6ee7b7"),
|
||||
t(290, 234, "Job runs inside redirect", 11, anchor="middle", fill="#94a3b8"),
|
||||
t(290, 256, "Works without queue worker", 11, anchor="middle", fill="#94a3b8"),
|
||||
t(290, 278, "GeoIP, webhook, GA4 add latency", 10, anchor="middle", fill="#64748b"),
|
||||
]), accent="#34d399", rx=22)
|
||||
body += card(500, 180, 340, 110, "\n ".join([
|
||||
t(670, 210, "redis / database", 15, "700", anchor="middle", fill="#93c5fd"),
|
||||
t(670, 234, "302 sent first", 11, anchor="middle", fill="#94a3b8"),
|
||||
t(670, 256, "Requires queue:work", 11, anchor="middle", fill="#94a3b8"),
|
||||
t(670, 278, "Settings - General", 10, anchor="middle", fill="#64748b"),
|
||||
]), accent="#60a5fa", rx=22)
|
||||
body += t(480, 318, "Aggregation (schedule:run)", 14, "700", anchor="middle")
|
||||
body += card(140, 332, 200, 44, t(240, 360, "Raw visits 90d", 11, anchor="middle"), rx=14, accent="#f87171")
|
||||
body += line(340, 354, 400, 354)
|
||||
body += card(400, 332, 240, 44, t(520, 360, "AggregateAndPrune", 11, anchor="middle"), rx=14, accent="#fbbf24")
|
||||
body += line(640, 354, 700, 354)
|
||||
body += card(700, 332, 200, 44, t(800, 360, "daily_stats", 11, anchor="middle"), rx=14, accent="#4ade80")
|
||||
body += card(80, 400, 800, 90, "\n ".join([
|
||||
t(480, 430, "Filament: FilteredStatsCollector + cross-dimensional rollups", 13, "600", anchor="middle"),
|
||||
t(480, 454, "API stats: date_from / date_to only (cross-filters = open gap)", 11, anchor="middle", fill="#94a3b8"),
|
||||
t(480, 476, "Long-range reports use rollups after raw prune", 10, anchor="middle", fill="#64748b"),
|
||||
]), accent="#818cf8", rx=24)
|
||||
return body + FOOT
|
||||
|
||||
|
||||
def diagram_08():
|
||||
h, oy = 560, 500
|
||||
body = GLASS_HEAD.format(height=h, label="Audit trust summary", title="v5.2 audit trust summary", subtitle="Promise vs old reality vs current status", orb_y=oy)
|
||||
rows = [
|
||||
("Sub-15ms / stateless", "web on /s/[key]", "FIXED: throttle only", "#34d399"),
|
||||
("Multi-tenant panel", "All admins see all links", "FIXED: LinkUserScope", "#34d399"),
|
||||
("API exists", "Ignored domain scope", "FIXED: domain_scope_id", "#34d399"),
|
||||
("Domain ownership", "Foreign domain ID", "FIXED: CustomDomainValidator", "#34d399"),
|
||||
("Safe Browsing", "Save only", "FIXED: redirect cache", "#34d399"),
|
||||
("Webhook HMAC", "Optional secret", "FIXED: required when on", "#34d399"),
|
||||
("lock_url_key", "UI disabled only", "FIXED: LockedUrlKeyGuard", "#34d399"),
|
||||
("Per-link webhook events", "Documented per-link", "OPEN: global list", "#fbbf24"),
|
||||
("Default queue async", "README vs sync", "BY DESIGN: sync default", "#fbbf24"),
|
||||
("Dub parity table", "Overstated", "FIXED: scoped compare", "#34d399"),
|
||||
]
|
||||
y = 92
|
||||
body += card(48, y, 864, 36, "\n ".join([
|
||||
t(180, y + 24, "Audit promise", 12, "700", anchor="middle"),
|
||||
t(440, y + 24, "Old reality", 12, "700", anchor="middle"),
|
||||
t(730, y + 24, "v5.2 status", 12, "700", anchor="middle"),
|
||||
]), rx=14, accent="#ffffff")
|
||||
y += 48
|
||||
for promise, old, status, color in rows:
|
||||
body += card(48, y, 864, 34, "\n ".join([
|
||||
t(64, y + 22, promise, 11),
|
||||
t(330, y + 22, old, 11, fill="#94a3b8"),
|
||||
t(600, y + 22, status, 11, "600", fill=color),
|
||||
]), rx=10, accent=color if "FIXED" in status else "#fbbf24")
|
||||
y += 40
|
||||
body += card(48, y + 8, 864, 72, "\n ".join([
|
||||
t(480, y + 36, "Product boundary: Laravel Filament plugin, not Dub clone", 13, "600", anchor="middle", fill="#e9d5ff"),
|
||||
t(480, y + 58, "Open: OpenAPI, API stats filters, workspaces, conversion, load tests", 11, anchor="middle", fill="#94a3b8"),
|
||||
]), rx=20, accent="#818cf8")
|
||||
return body + FOOT
|
||||
|
||||
|
||||
DIAGRAMS = [
|
||||
("01-system-overview.svg", diagram_01),
|
||||
("02-redirect-lifecycle.svg", diagram_02),
|
||||
("03-stateless-vs-stateful.svg", diagram_03),
|
||||
("04-custom-domain-routing.svg", diagram_04),
|
||||
("05-targeting-and-ab.svg", diagram_05),
|
||||
("06-webhooks-and-security.svg", diagram_06),
|
||||
("07-stats-and-queue.svg", diagram_07),
|
||||
("08-audit-trust-summary.svg", diagram_08),
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name, fn in DIAGRAMS:
|
||||
path = OUT / name
|
||||
path.write_text(fn(), encoding="utf-8")
|
||||
print(f"Wrote {name}")
|
||||
BIN
art/plugin-filament-v2.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
art/v3_1.png
|
Before Width: | Height: | Size: 241 KiB |
BIN
art/v3_1r.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
art/v3_2.png
|
Before Width: | Height: | Size: 251 KiB |
BIN
art/v3_2r.png
Normal file
|
After Width: | Height: | Size: 285 KiB |
BIN
art/v3_3.png
|
Before Width: | Height: | Size: 467 KiB |
BIN
art/v3_3r.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
art/v3_4.png
|
Before Width: | Height: | Size: 252 KiB |
BIN
art/v3_4r.png
Normal file
|
After Width: | Height: | Size: 285 KiB |
BIN
art/v3_5.png
|
Before Width: | Height: | Size: 460 KiB |
BIN
art/v3_5r.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
24
composer.lock
generated
@@ -1676,16 +1676,16 @@
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "7.11.0",
|
||||
"version": "7.11.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/guzzle.git",
|
||||
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b"
|
||||
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b",
|
||||
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
|
||||
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1704,7 +1704,7 @@
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"ext-curl": "*",
|
||||
"guzzle/client-integration-tests": "3.0.2",
|
||||
"guzzlehttp/test-server": "^0.4",
|
||||
"guzzlehttp/test-server": "^0.5",
|
||||
"php-http/message-factory": "^1.1",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
|
||||
"psr/log": "^1.1 || ^2.0 || ^3.0"
|
||||
@@ -1784,7 +1784,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.11.0"
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.11.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1800,7 +1800,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-02T12:40:51+00:00"
|
||||
"time": "2026-06-07T22:54:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/promises",
|
||||
@@ -2156,16 +2156,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.61.0",
|
||||
"version": "v12.61.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf"
|
||||
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/1124062a1ca92d290c8bcb9b7f649920fa6816bf",
|
||||
"reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d",
|
||||
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2374,7 +2374,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-05-26T23:41:33+00:00"
|
||||
"time": "2026-06-04T14:22:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/prompts",
|
||||
|
||||
@@ -61,6 +61,17 @@ return [
|
||||
*/
|
||||
'disable_default_domain' => env('SHORT_URL_DISABLE_DEFAULT_DOMAIN', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Domains
|
||||
|--------------------------------------------------------------------------
|
||||
| enforce_dns_on_activate: when true, activating a domain (or changing its
|
||||
| hostname while active) requires a passing DNS check against app.url.
|
||||
*/
|
||||
'custom_domains' => [
|
||||
'enforce_dns_on_activate' => env('SHORT_URL_CUSTOM_DOMAIN_ENFORCE_DNS', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Geo-IP Settings
|
||||
@@ -120,7 +131,8 @@ return [
|
||||
| Queue Connection
|
||||
|--------------------------------------------------------------------------
|
||||
| Visit tracking is dispatched to a queue for ultra-fast redirects.
|
||||
| Set to null to run synchronously (not recommended in production).
|
||||
| Default is `sync` (no background worker required). Switch to `database` or
|
||||
| `redis` in Settings or via SHORT_URL_QUEUE when you run a queue worker.
|
||||
*/
|
||||
'queue_connection' => env('SHORT_URL_QUEUE', 'sync'),
|
||||
|
||||
@@ -133,6 +145,22 @@ return [
|
||||
*/
|
||||
'queue_name' => env('SHORT_URL_QUEUE_NAME', 'default'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis (Settings override when Queue Connection = redis)
|
||||
|--------------------------------------------------------------------------
|
||||
| Defaults from .env; overridden at runtime from Settings → General when
|
||||
| queue_connection is redis. Used for queue driver, visit counters, stats,
|
||||
| and live feed — independent of CACHE_STORE.
|
||||
*/
|
||||
'redis' => [
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => (int) env('REDIS_PORT', 6379),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => (int) env('REDIS_DB', 0),
|
||||
'prefix' => env('REDIS_PREFIX', ''),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Cache TTL
|
||||
@@ -161,8 +189,6 @@ return [
|
||||
| Default design settings for newly generated QR codes.
|
||||
*/
|
||||
'qr_defaults' => [
|
||||
'size' => 300,
|
||||
'margin' => 1,
|
||||
'dot_style' => 'square',
|
||||
'foreground_color' => '#000000',
|
||||
'background_color' => '#ffffff',
|
||||
@@ -197,6 +223,58 @@ return [
|
||||
*/
|
||||
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Developer REST API
|
||||
|--------------------------------------------------------------------------
|
||||
| Enable programmatic access to short URLs via /api/short-url/* endpoints.
|
||||
| API keys are managed in the Filament settings panel.
|
||||
|
|
||||
*/
|
||||
'api_enabled' => env('SHORT_URL_API_ENABLED', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| VPN & Proxy Detection
|
||||
|--------------------------------------------------------------------------
|
||||
| Detect VPN, proxy, and Tor connections on incoming visits.
|
||||
|
|
||||
*/
|
||||
'vpn_detection' => [
|
||||
'enabled' => env('SHORT_URL_VPN_DETECTION', false),
|
||||
'driver' => env('SHORT_URL_VPN_DRIVER', 'ip-api'),
|
||||
'vpnapi_key' => env('SHORT_URL_VPNAPI_KEY'),
|
||||
'block_action' => env('SHORT_URL_VPN_BLOCK_ACTION', 'flag_only'),
|
||||
'cache_ttl' => 86400,
|
||||
'timeout' => 2,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Safe Browsing
|
||||
|--------------------------------------------------------------------------
|
||||
| Block phishing and malware URLs at creation time.
|
||||
|
|
||||
*/
|
||||
'safe_browsing' => [
|
||||
'enabled' => env('SHORT_URL_SAFE_BROWSING', false),
|
||||
'api_key' => env('SHORT_URL_SAFE_BROWSING_KEY'),
|
||||
'check_on_redirect' => env('SHORT_URL_SAFE_BROWSING_ON_REDIRECT', true),
|
||||
'redirect_cache_ttl' => (int) env('SHORT_URL_SAFE_BROWSING_REDIRECT_CACHE_TTL', 3600),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Click Deduplication
|
||||
|--------------------------------------------------------------------------
|
||||
| Ignore repeated clicks from the same IP within the configured window.
|
||||
|
|
||||
*/
|
||||
'click_deduplication' => [
|
||||
'enabled' => env('SHORT_URL_CLICK_DEDUP', false),
|
||||
'hours' => (int) env('SHORT_URL_CLICK_DEDUP_HOURS', 1),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Data Pruning & Aggregation
|
||||
@@ -237,15 +315,56 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
| The middleware list applied to the short URL redirect route.
|
||||
| By default, standard web middleware and rate limiting are applied.
|
||||
| Middleware applied to the main short URL redirect route (/s/{key}).
|
||||
| Intentionally excludes the "web" group (sessions/cookies) for a lean
|
||||
| hot path. Password routes always load "web" separately (/s-auth/{key}).
|
||||
| Add "web" here only if your app requires session on every redirect.
|
||||
|
|
||||
*/
|
||||
'middleware' => [
|
||||
'web',
|
||||
'throttle:120,1',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Link ownership scoping
|
||||
|--------------------------------------------------------------------------
|
||||
| When true, authenticated Filament users only see links they created
|
||||
| (short_urls.user_id). Disable for shared admin panels.
|
||||
|
|
||||
*/
|
||||
'scope_links_to_user' => env('SHORT_URL_SCOPE_TO_USER', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Max visits pessimistic locking
|
||||
|--------------------------------------------------------------------------
|
||||
| When remaining slots are above this threshold, use a lock-free UPDATE path.
|
||||
| Near the cap, a row lock is used to stay exact under concurrent traffic.
|
||||
*/
|
||||
'max_visits_pessimistic_remaining' => (int) env('SHORT_URL_MAX_VISITS_PESSIMISTIC_REMAINING', 5),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Live feed (SSE)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'live_feed' => [
|
||||
'sse_interval_seconds' => (int) env('SHORT_URL_LIVE_FEED_SSE_INTERVAL', 3),
|
||||
'sse_max_duration_seconds' => (int) env('SHORT_URL_LIVE_FEED_SSE_MAX_DURATION', 120),
|
||||
// When the cache store is Redis, SSE blocks on pub/sub instead of sleep-polling.
|
||||
'use_redis_push' => env('SHORT_URL_LIVE_FEED_REDIS_PUSH', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Webhook signing
|
||||
|--------------------------------------------------------------------------
|
||||
| Require a signing secret before dispatching global webhooks.
|
||||
|
|
||||
*/
|
||||
'webhook_signing_required' => env('SHORT_URL_WEBHOOK_SIGNING_REQUIRED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deep Linking v2.1
|
||||
@@ -272,4 +391,111 @@ return [
|
||||
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bot Detection
|
||||
|--------------------------------------------------------------------------
|
||||
| Patterns used to identify crawlers and link-preview bots. Specific tokens
|
||||
| avoid false positives from real browsers (e.g. Chrome never matches "googlebot").
|
||||
|
|
||||
*/
|
||||
'bot_detection' => [
|
||||
'user_agent_contains' => [
|
||||
'facebookexternalhit',
|
||||
'meta-externalagent',
|
||||
'meta-externalfetcher',
|
||||
'meta-externalads',
|
||||
'meta-webindexer',
|
||||
'Googlebot',
|
||||
'Google-InspectionTool',
|
||||
'GoogleOther',
|
||||
'Storebot-Google',
|
||||
'bingbot',
|
||||
'BingPreview',
|
||||
'DuckDuckBot',
|
||||
'Slurp',
|
||||
'Baiduspider',
|
||||
'YandexBot',
|
||||
'Applebot',
|
||||
'Applebot-Extended',
|
||||
'Twitterbot',
|
||||
'LinkedInBot',
|
||||
'linkedinbot',
|
||||
'Slackbot',
|
||||
'Slack-ImgProxy',
|
||||
'Discordbot',
|
||||
'TelegramBot',
|
||||
'WhatsApp',
|
||||
'Pinterestbot',
|
||||
'Embedly',
|
||||
'Iframely',
|
||||
'SkypeUriPreview',
|
||||
'facebookcatalog',
|
||||
'MetaInspector',
|
||||
'vkShare',
|
||||
'Tumblr',
|
||||
'redditbot',
|
||||
'Snap URL Preview',
|
||||
'Snapchat',
|
||||
'ChatGPT-User',
|
||||
'Claude-Web',
|
||||
'anthropic-ai',
|
||||
'PerplexityBot',
|
||||
'Bytespider',
|
||||
'GPTBot',
|
||||
'OAI-SearchBot',
|
||||
'HeadlessChrome',
|
||||
'Go-http-client',
|
||||
'python-requests',
|
||||
'axios/',
|
||||
'GuzzleHttp',
|
||||
'PostmanRuntime',
|
||||
'Insomnia',
|
||||
'Scrapy',
|
||||
'FeedBurner',
|
||||
'W3C_Validator',
|
||||
'Validator.nu',
|
||||
'PocketParser',
|
||||
'BitlyBot',
|
||||
'rogerbot',
|
||||
'SemrushBot',
|
||||
'AhrefsBot',
|
||||
'MJ12bot',
|
||||
'DotBot',
|
||||
'PetalBot',
|
||||
'Sogou',
|
||||
'ia_archiver',
|
||||
'archive.org_bot',
|
||||
'Wayback',
|
||||
'UptimeRobot',
|
||||
'StatusCake',
|
||||
'Pingdom',
|
||||
'GTmetrix',
|
||||
'Bluesky',
|
||||
'thirdLandingPageFeInfra',
|
||||
'ShortLinkTranslate',
|
||||
],
|
||||
'user_agent_regex' => [
|
||||
'/bot[\s\/;\)]/i',
|
||||
'/crawler[\s\/;\)]/i',
|
||||
'/spider[\s\/;\)]/i',
|
||||
'/\bscraper\b/i',
|
||||
'/\bslurp\b/i',
|
||||
'/\bpreview\b/i',
|
||||
'/^curl\//i',
|
||||
'/^wget\//i',
|
||||
'/\bPHP\/[\d.]+/i',
|
||||
],
|
||||
'referer_contains' => [
|
||||
'url.emailprotection.link',
|
||||
'urlsand.com',
|
||||
'statics.teams.cdn.office.net',
|
||||
'security-za.m.mimecastprotect.com',
|
||||
'deref-mail.com',
|
||||
'deref-gmx.com',
|
||||
],
|
||||
'verify_google_bot_ip' => env('SHORT_URL_VERIFY_GOOGLEBOT_IP', false),
|
||||
'debug_secret' => env('SHORT_URL_BOT_DEBUG_SECRET'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('og_title')->nullable()->after('folder_id');
|
||||
$table->text('og_description')->nullable()->after('og_title');
|
||||
$table->string('og_image')->nullable()->after('og_description');
|
||||
$table->boolean('is_cloaked')->default(false)->after('og_image');
|
||||
$table->boolean('do_index')->default(false)->after('is_cloaked');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['og_title', 'og_description', 'og_image', 'is_cloaked', 'do_index']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('external_id')->nullable()->unique();
|
||||
$table->string('utm_source')->nullable();
|
||||
$table->string('utm_medium')->nullable();
|
||||
$table->string('utm_campaign')->nullable();
|
||||
$table->string('utm_term')->nullable();
|
||||
$table->string('utm_content')->nullable();
|
||||
$table->string('ref')->nullable();
|
||||
$table->boolean('public_stats_enabled')->default(false);
|
||||
$table->string('public_stats_password')->nullable();
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table) {
|
||||
$table->index(['short_url_id', 'ip_hash'], 'short_url_visits_url_ip_hash_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table) {
|
||||
$table->dropIndex('short_url_visits_url_ip_hash_index');
|
||||
});
|
||||
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'external_id',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'ref',
|
||||
'public_stats_enabled',
|
||||
'public_stats_password',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('domain_scope_id')->default(0);
|
||||
});
|
||||
|
||||
DB::table('short_urls')->whereNotNull('custom_domain_id')->update([
|
||||
'domain_scope_id' => DB::raw('custom_domain_id'),
|
||||
]);
|
||||
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropUnique(['url_key']);
|
||||
$table->unique(['url_key', 'domain_scope_id'], 'short_urls_url_key_domain_scope_unique');
|
||||
$table->index(['user_id', 'is_archived', 'id'], 'short_urls_user_archived_id_index');
|
||||
});
|
||||
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->foreign('custom_domain_id')
|
||||
->references('id')
|
||||
->on('short_url_custom_domains')
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table) {
|
||||
$table->index(['short_url_id', 'id'], 'short_url_visits_url_id_index');
|
||||
});
|
||||
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table) {
|
||||
$table->json('utm_terms')->nullable();
|
||||
$table->json('utm_contents')->nullable();
|
||||
$table->json('browser_versions')->nullable();
|
||||
$table->json('os_versions')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table) {
|
||||
$table->dropColumn(['utm_terms', 'utm_contents', 'browser_versions', 'os_versions']);
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table) {
|
||||
$table->dropIndex('short_url_visits_url_id_index');
|
||||
});
|
||||
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropForeign(['custom_domain_id']);
|
||||
$table->dropIndex('short_urls_user_archived_id_index');
|
||||
$table->dropUnique('short_urls_url_key_domain_scope_unique');
|
||||
$table->unique('url_key');
|
||||
$table->dropColumn('domain_scope_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table) {
|
||||
$table->unsignedInteger('all_visits_count')->default(0)->after('visits_count');
|
||||
$table->unsignedInteger('bot_visits_count')->default(0)->after('all_visits_count');
|
||||
$table->unsignedInteger('proxy_visits_count')->default(0)->after('bot_visits_count');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table) {
|
||||
$table->dropColumn(['all_visits_count', 'bot_visits_count', 'proxy_visits_count']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table) {
|
||||
$table->json('cross_dimensional_stats')->nullable();
|
||||
$table->json('cross_filter_pairs')->nullable();
|
||||
$table->json('filter_qr_counts')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table) {
|
||||
$table->dropColumn(['cross_dimensional_stats', 'cross_filter_pairs', 'filter_qr_counts']);
|
||||
});
|
||||
}
|
||||
};
|
||||
9
phpstan.neon.dist
Normal file
@@ -0,0 +1,9 @@
|
||||
includes:
|
||||
- vendor/larastan/larastan/extension.neon
|
||||
|
||||
parameters:
|
||||
paths:
|
||||
- src
|
||||
level: 5
|
||||
tmpDir: build/phpstan
|
||||
checkMissingIterableValueType: false
|
||||
2
resources/dist/filament-short-url.css
vendored
935
resources/dist/filament-short-url.js
vendored
Normal file
@@ -0,0 +1,935 @@
|
||||
if (!window.QrHelper) {
|
||||
window.QrHelper = {
|
||||
scriptPromise: null,
|
||||
loadScript() {
|
||||
if (window.QRCodeStyling) return Promise.resolve();
|
||||
if (this.scriptPromise) return this.scriptPromise;
|
||||
|
||||
this.scriptPromise = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.id = 'qr-styling-script-tag';
|
||||
s.src = '/js/janczakb/filament-short-url/qr-code-styling.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return this.scriptPromise;
|
||||
},
|
||||
|
||||
processLogo(logo, logoShape, logoMargin) {
|
||||
if (!logo) return Promise.resolve('');
|
||||
|
||||
const processImage = (img, hasCrossOrigin) => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1200;
|
||||
canvas.height = 1200;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = logoShape === 'circle';
|
||||
const targetDim = 1200;
|
||||
|
||||
ctx.save();
|
||||
if (isCircle) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
ctx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof ctx.roundRect === 'function') {
|
||||
ctx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
ctx.moveTo(offset + radius, offset);
|
||||
ctx.lineTo(offset + targetDim - radius, offset);
|
||||
ctx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
ctx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
ctx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
ctx.lineTo(offset + radius, offset + targetDim);
|
||||
ctx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
ctx.lineTo(offset, offset + radius);
|
||||
ctx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch (e) {
|
||||
if (hasCrossOrigin) throw e;
|
||||
return logo;
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
const srcUrl = (logo.startsWith('data:') || logo.startsWith('blob:'))
|
||||
? logo
|
||||
: logo + (logo.includes('?') ? '&' : '?') + 't=' + new Date().getTime();
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
resolve(processImage(img, true));
|
||||
} catch (e) {
|
||||
loadWithoutCORS();
|
||||
}
|
||||
};
|
||||
|
||||
const loadWithoutCORS = () => {
|
||||
const imgRetry = new Image();
|
||||
imgRetry.onload = () => {
|
||||
try {
|
||||
resolve(processImage(imgRetry, false));
|
||||
} catch (err) {
|
||||
resolve(logo);
|
||||
}
|
||||
};
|
||||
imgRetry.onerror = () => {
|
||||
resolve('');
|
||||
};
|
||||
imgRetry.src = srcUrl;
|
||||
};
|
||||
|
||||
img.onerror = loadWithoutCORS;
|
||||
img.src = srcUrl;
|
||||
});
|
||||
},
|
||||
|
||||
buildOptions(state, url, size) {
|
||||
const isGrad = state.colorMode === 'gradient';
|
||||
const dotsOptions = isGrad
|
||||
? { type: state.dotStyle, gradient: { type: state.gradientType,
|
||||
colorStops: [{ offset: 0, color: state.gradientFrom }, { offset: 1, color: state.gradientTo }] } }
|
||||
: { type: state.dotStyle, color: state.fgColor };
|
||||
|
||||
const mainColor = isGrad ? state.gradientFrom : state.fgColor;
|
||||
const eyeSq = state.eyeConfigEnabled
|
||||
? { type: state.eyeSquareStyle, color: state.eyeColor }
|
||||
: { type: state.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
|
||||
const eyeDt = state.eyeConfigEnabled
|
||||
? { type: state.eyeDotStyle, color: state.eyeColor }
|
||||
: { type: state.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
|
||||
|
||||
const options = {
|
||||
type: 'svg',
|
||||
width: size, height: size,
|
||||
data: url, margin: 1,
|
||||
dotsOptions,
|
||||
backgroundOptions: state.bgTransparent ? { color: 'rgba(0,0,0,0)' } : { color: state.bgColor },
|
||||
cornersSquareOptions: eyeSq,
|
||||
cornersDotOptions: eyeDt,
|
||||
qrOptions: { errorCorrectionLevel: state.logo ? 'H' : 'M' },
|
||||
};
|
||||
|
||||
if (state.processedLogo) {
|
||||
options.image = state.processedLogo;
|
||||
options.imageOptions = {
|
||||
crossOrigin: 'anonymous',
|
||||
hideBackgroundDots: false, // Override to false so we can apply our mask instead of grid clearing
|
||||
imageSize: parseFloat(state.logoSize) || 0.3,
|
||||
margin: 0, // Override to 0 so the logo size remains constant
|
||||
logoShape: state.logoShape,
|
||||
actualMargin: state.logoMargin // Keep the actual margin here for our mask processor
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
|
||||
render(el, opts, fixIds = false) {
|
||||
if (!window.QRCodeStyling || !el) return null;
|
||||
el.innerHTML = '';
|
||||
const qr = new window.QRCodeStyling(opts);
|
||||
qr.append(el);
|
||||
|
||||
const promise = qr._svgDrawingPromise || Promise.resolve();
|
||||
qr.drawingPromise = promise.then(() => {
|
||||
const svg = el.querySelector('svg');
|
||||
if (svg) {
|
||||
const prefix = 'qr-' + Math.random().toString(36).substr(2, 9);
|
||||
if (fixIds) {
|
||||
const elementsWithId = svg.querySelectorAll('[id]');
|
||||
const idMap = new Map();
|
||||
|
||||
elementsWithId.forEach(elem => {
|
||||
const oldId = elem.id;
|
||||
const newId = prefix + '-' + oldId;
|
||||
elem.id = newId;
|
||||
idMap.set(oldId, newId);
|
||||
});
|
||||
|
||||
const attrsToUpdate = ['fill', 'stroke', 'clip-path', 'filter'];
|
||||
attrsToUpdate.forEach(attrName => {
|
||||
svg.querySelectorAll(`[${attrName}]`).forEach(elem => {
|
||||
const val = elem.getAttribute(attrName);
|
||||
if (val && val.includes('url(')) {
|
||||
const match = val.match(/url\(\s*['"]?#([^'"]+?)['"]?\s*\)/);
|
||||
if (match && match[1]) {
|
||||
const oldId = match[1];
|
||||
if (idMap.has(oldId)) {
|
||||
elem.setAttribute(attrName, `url(#${idMap.get(oldId)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
svg.querySelectorAll('use, image').forEach(elem => {
|
||||
['href', 'xlink:href'].forEach(attrName => {
|
||||
const val = elem.getAttribute(attrName);
|
||||
if (val && val.startsWith('#')) {
|
||||
const oldId = val.substring(1);
|
||||
if (idMap.has(oldId)) {
|
||||
elem.setAttribute(attrName, `#${idMap.get(oldId)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Custom shape clearing for logo background dots via SVG mask
|
||||
const logoShape = opts.imageOptions?.logoShape;
|
||||
if (logoShape) {
|
||||
const width = parseFloat(svg.getAttribute('width')) || opts.width || 300;
|
||||
const height = parseFloat(svg.getAttribute('height')) || opts.height || 300;
|
||||
const imageSize = opts.imageOptions.imageSize || 0.3;
|
||||
const logoMargin = parseFloat(opts.imageOptions.actualMargin) || 0;
|
||||
// Scale the margin proportionally to the canvas size (default base width is 280)
|
||||
const scaledMargin = logoMargin * (width / 280);
|
||||
|
||||
// Calculate exact logo size matching QRCodeStyling's internal drawQR/drawImage hidden dots logic
|
||||
let hideXDots = 7;
|
||||
let dotSize = 9;
|
||||
if (qr._qr) {
|
||||
const e = qr._qr.getModuleCount();
|
||||
const r = width - 2 * (opts.margin || 0);
|
||||
dotSize = Math.floor(r / e);
|
||||
|
||||
const errorCorrectionLevel = opts.qrOptions?.errorCorrectionLevel || 'M';
|
||||
const coefficients = { L: 0.07, M: 0.15, Q: 0.25, H: 0.3 };
|
||||
const coeff = coefficients[errorCorrectionLevel] || 0.15;
|
||||
const c = imageSize * coeff;
|
||||
const l = Math.floor(c * e * e);
|
||||
|
||||
hideXDots = Math.floor(Math.sqrt(l));
|
||||
if (hideXDots <= 0) hideXDots = 1;
|
||||
const maxHiddenAxisDots = e - 14;
|
||||
if (maxHiddenAxisDots > 0 && hideXDots > maxHiddenAxisDots) {
|
||||
hideXDots = maxHiddenAxisDots;
|
||||
}
|
||||
if (hideXDots % 2 === 0) {
|
||||
hideXDots--;
|
||||
}
|
||||
if (hideXDots < 1) hideXDots = 1;
|
||||
} else {
|
||||
// Fallback estimate for standard QR version 3
|
||||
hideXDots = Math.floor(Math.sqrt(imageSize * 0.3 * 29 * 29));
|
||||
if (hideXDots % 2 === 0) hideXDots--;
|
||||
dotSize = width / 29;
|
||||
}
|
||||
const libraryClearedSize = hideXDots * dotSize;
|
||||
const clearedSize = libraryClearedSize + 2 * scaledMargin;
|
||||
|
||||
const defs = svg.querySelector('defs') || svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'defs'));
|
||||
if (defs) {
|
||||
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'mask');
|
||||
mask.setAttribute('id', `${prefix}-qr-logo-mask`);
|
||||
|
||||
const maskBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
maskBg.setAttribute('x', '0');
|
||||
maskBg.setAttribute('y', '0');
|
||||
maskBg.setAttribute('width', width.toString());
|
||||
maskBg.setAttribute('height', height.toString());
|
||||
maskBg.setAttribute('fill', 'white');
|
||||
mask.appendChild(maskBg);
|
||||
|
||||
if (logoShape === 'circle') {
|
||||
const maskCut = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
maskCut.setAttribute('cx', (width / 2).toString());
|
||||
maskCut.setAttribute('cy', (height / 2).toString());
|
||||
maskCut.setAttribute('r', (clearedSize / 2).toString());
|
||||
maskCut.setAttribute('fill', 'black');
|
||||
mask.appendChild(maskCut);
|
||||
} else {
|
||||
const maskCut = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
const x = width / 2 - clearedSize / 2;
|
||||
const y = height / 2 - clearedSize / 2;
|
||||
maskCut.setAttribute('x', x.toString());
|
||||
maskCut.setAttribute('y', y.toString());
|
||||
maskCut.setAttribute('width', clearedSize.toString());
|
||||
maskCut.setAttribute('height', clearedSize.toString());
|
||||
maskCut.setAttribute('rx', (clearedSize * 0.12).toString());
|
||||
maskCut.setAttribute('ry', (clearedSize * 0.12).toString());
|
||||
maskCut.setAttribute('fill', 'black');
|
||||
mask.appendChild(maskCut);
|
||||
}
|
||||
|
||||
defs.appendChild(mask);
|
||||
|
||||
// Find the dots element and wrap it in a masked group
|
||||
const dotsElement = svg.querySelector('[clip-path*="clip-path-dot-color"]');
|
||||
if (dotsElement) {
|
||||
const parent = dotsElement.parentNode;
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
g.setAttribute('mask', `url(#${prefix}-qr-logo-mask)`);
|
||||
parent.replaceChild(g, dotsElement);
|
||||
g.appendChild(dotsElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newSvg = el.querySelector('svg');
|
||||
if (newSvg) {
|
||||
const w = newSvg.getAttribute('width') || opts.width || 300;
|
||||
const h = newSvg.getAttribute('height') || opts.height || 300;
|
||||
newSvg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
newSvg.style.width = '100%';
|
||||
newSvg.style.height = '100%';
|
||||
newSvg.style.maxWidth = '100%';
|
||||
newSvg.style.maxHeight = '100%';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return qr;
|
||||
}
|
||||
};
|
||||
window.QrHelper.loadScript();
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('shortUrlQrPreview', (config) => ({
|
||||
qrInstance: null,
|
||||
url: '',
|
||||
processedLogo: '',
|
||||
|
||||
dotStyle: config.options.dot_style ?? 'square',
|
||||
colorMode: config.options.color_mode ?? 'solid',
|
||||
fgColor: config.options.foreground_color ?? '#000000',
|
||||
gradientFrom: config.options.gradient_from ?? '#4f46e5',
|
||||
gradientTo: config.options.gradient_to ?? '#06b6d4',
|
||||
gradientType: config.options.gradient_type ?? 'linear',
|
||||
bgTransparent: !!config.options.bg_transparent,
|
||||
bgColor: config.options.background_color ?? '#ffffff',
|
||||
eyeConfigEnabled: !!config.options.eye_config_enabled,
|
||||
eyeSquareStyle: config.options.eye_square_style ?? 'square',
|
||||
eyeDotStyle: config.options.eye_dot_style ?? 'square',
|
||||
eyeColor: config.options.eye_color ?? '#000000',
|
||||
logo: config.logo ?? '',
|
||||
logoSize: config.options.logo_size ?? 0.55,
|
||||
logoMargin: config.options.logo_margin ?? 8,
|
||||
logoHideBackground: true,
|
||||
logoShape: config.options.logo_shape ?? 'square',
|
||||
|
||||
domains: config.domains,
|
||||
defaultDomain: config.defaultDomain,
|
||||
routePrefix: config.routePrefix,
|
||||
defaultUrlKey: config.defaultUrlKey,
|
||||
protocol: config.protocol || 'https',
|
||||
|
||||
getLogoUrl(logoFile) {
|
||||
if (!logoFile) return '';
|
||||
|
||||
let logoPath = null;
|
||||
if (typeof logoFile === 'string') {
|
||||
logoPath = logoFile;
|
||||
} else if (Array.isArray(logoFile)) {
|
||||
logoPath = logoFile[0];
|
||||
} else if (logoFile && typeof logoFile === 'object') {
|
||||
const vals = Object.values(logoFile);
|
||||
if (vals.length > 0) {
|
||||
logoPath = vals[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!logoPath || typeof logoPath !== 'string') return '';
|
||||
|
||||
if (/^(https?:|data:)/.test(logoPath)) {
|
||||
return logoPath;
|
||||
}
|
||||
|
||||
if (logoPath.startsWith('short-urls/')) {
|
||||
return `/storage/${logoPath}`;
|
||||
}
|
||||
|
||||
return `/short-url/logo/${logoPath.split('/').pop()}`;
|
||||
},
|
||||
|
||||
init() {
|
||||
this.designUpdatedHandler = (event) => {
|
||||
// Livewire v4 dispatches plain objects; v3 wrapped them in an array.
|
||||
const payload = Array.isArray(event.detail) ? event.detail[0] : event.detail;
|
||||
if (payload?.options) {
|
||||
this.applyQrDesign(payload.options, payload.logo ?? null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('qr-design-updated', this.designUpdatedHandler);
|
||||
|
||||
this.$nextTick(() => {
|
||||
const optionsInput = document.getElementById('qr-options-json-input');
|
||||
if (optionsInput && optionsInput.value && optionsInput.value !== '[object Object]') {
|
||||
try {
|
||||
const val = JSON.parse(optionsInput.value);
|
||||
if (val && typeof val === 'object') {
|
||||
this.applyState(val);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('QR Preview: Error parsing initial options:', err);
|
||||
}
|
||||
}
|
||||
const logoInput = document.getElementById('qr-logo-path-input');
|
||||
if (logoInput && logoInput.value) {
|
||||
this.logo = this.getLogoUrl(logoInput.value);
|
||||
}
|
||||
this.updateUrl();
|
||||
|
||||
window.QrHelper.loadScript().then(() => {
|
||||
this.updateProcessedLogo().then(() => {
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const container = this.$el.closest('form') ||
|
||||
this.$el.closest('.fi-modal-window') ||
|
||||
this.$el.closest('.fi-layout') ||
|
||||
document;
|
||||
|
||||
this.inputHandler = (e) => {
|
||||
if (e.target) {
|
||||
const name = e.target.name || '';
|
||||
const id = e.target.id || '';
|
||||
if (name.includes('url_key') || id.includes('url_key') ||
|
||||
name.includes('custom_domain_id') || id.includes('custom_domain_id')) {
|
||||
this.updateUrl();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('input', this.inputHandler);
|
||||
container.addEventListener('change', this.inputHandler);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.designUpdatedHandler) {
|
||||
window.removeEventListener('qr-design-updated', this.designUpdatedHandler);
|
||||
}
|
||||
const container = this.$el.closest('form') ||
|
||||
this.$el.closest('.fi-modal-window') ||
|
||||
this.$el.closest('.fi-layout') ||
|
||||
document;
|
||||
if (this.inputHandler) {
|
||||
container.removeEventListener('input', this.inputHandler);
|
||||
container.removeEventListener('change', this.inputHandler);
|
||||
}
|
||||
},
|
||||
|
||||
applyState(val) {
|
||||
if (val.dot_style !== undefined) this.dotStyle = val.dot_style;
|
||||
if (val.color_mode !== undefined) this.colorMode = val.color_mode;
|
||||
if (val.foreground_color !== undefined) this.fgColor = val.foreground_color;
|
||||
if (val.gradient_from !== undefined) this.gradientFrom = val.gradient_from;
|
||||
if (val.gradient_to !== undefined) this.gradientTo = val.gradient_to;
|
||||
if (val.gradient_type !== undefined) this.gradientType = val.gradient_type;
|
||||
if (val.bg_transparent !== undefined) this.bgTransparent = !!val.bg_transparent;
|
||||
if (val.background_color !== undefined) this.bgColor = val.background_color;
|
||||
if (val.eye_config_enabled !== undefined) this.eyeConfigEnabled = !!val.eye_config_enabled;
|
||||
if (val.eye_square_style !== undefined) this.eyeSquareStyle = val.eye_square_style;
|
||||
if (val.eye_dot_style !== undefined) this.eyeDotStyle = val.eye_dot_style;
|
||||
if (val.eye_color !== undefined) this.eyeColor = val.eye_color;
|
||||
if (val.logo_size !== undefined) this.logoSize = val.logo_size;
|
||||
if (val.logo_margin !== undefined) this.logoMargin = val.logo_margin;
|
||||
this.logoHideBackground = true;
|
||||
if (val.logo_shape !== undefined) this.logoShape = val.logo_shape;
|
||||
if (val.logo !== undefined) {
|
||||
this.logo = this.getLogoUrl(val.logo);
|
||||
}
|
||||
},
|
||||
|
||||
updateProcessedLogo() {
|
||||
return window.QrHelper.processLogo(this.logo, this.logoShape, this.logoMargin).then(res => {
|
||||
this.processedLogo = res;
|
||||
});
|
||||
},
|
||||
|
||||
calculateUrl() {
|
||||
const keyInput = document.querySelector('[name$="url_key"]') || document.querySelector('[id$="url_key"]');
|
||||
const key = keyInput?.value || this.defaultUrlKey || 'preview';
|
||||
|
||||
const domainSelect = document.querySelector('[name$="custom_domain_id"]') || document.querySelector('[id$="custom_domain_id"]');
|
||||
const domainId = domainSelect?.value || '';
|
||||
let domain = this.defaultDomain;
|
||||
|
||||
if (domainId && this.domains[domainId]) {
|
||||
domain = this.domains[domainId];
|
||||
}
|
||||
|
||||
if (this.routePrefix && !domainId) {
|
||||
return `${this.protocol}://${domain}/${this.routePrefix}/${key}`;
|
||||
}
|
||||
|
||||
return `${this.protocol}://${domain}/${key}`;
|
||||
},
|
||||
|
||||
updateUrl() {
|
||||
this.url = this.calculateUrl();
|
||||
},
|
||||
|
||||
buildOptions() {
|
||||
return window.QrHelper.buildOptions(this, this.url, 200);
|
||||
},
|
||||
|
||||
render() {
|
||||
const el = this.$refs.sidebarQrCanvas;
|
||||
if (el) {
|
||||
this.qrInstance = window.QrHelper.render(el, this.buildOptions(), true);
|
||||
}
|
||||
},
|
||||
|
||||
applyQrDesign(opts, logo = null) {
|
||||
if (!opts) return;
|
||||
|
||||
this.applyState(opts);
|
||||
|
||||
// Update logo if provided (path stored in qr_logo field)
|
||||
if (logo !== null) {
|
||||
this.logo = this.getLogoUrl(logo);
|
||||
}
|
||||
|
||||
this.updateProcessedLogo().then(() => {
|
||||
this.updateUrl();
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Alpine component powering the live QR preview inside the designer modal.
|
||||
*
|
||||
* Architecture
|
||||
* ────────────
|
||||
* Filament / Livewire v4 stores each action's form data at a container path
|
||||
* such as "mountedActions.1.data". The full container object is not exposed
|
||||
* via $wire.$get, but every scalar leaf (e.g. "mountedActions.1.data.dot_style")
|
||||
* resolves correctly. We discover the containerPath once from the nearest
|
||||
* filamentSchemaComponent ancestor, then register one $wire.$watch per design
|
||||
* field. These are purely client-side reactive subscriptions — zero network
|
||||
* overhead. All change callbacks are funnelled through a 50 ms debounce so
|
||||
* that a single user interaction producing N field changes yields one QR render.
|
||||
*/
|
||||
const QR_FIELDS = Object.freeze([
|
||||
'dot_style', 'color_mode',
|
||||
'foreground_color', 'gradient_from', 'gradient_to', 'gradient_type',
|
||||
'bg_transparent', 'background_color',
|
||||
'eye_config_enabled', 'eye_square_style', 'eye_dot_style', 'eye_color',
|
||||
'logo_shape', 'logo_size', 'logo_margin', 'logo_hide_background', 'logo_file',
|
||||
]);
|
||||
|
||||
Alpine.data('shortUrlQrDesignerPreview', (config) => ({
|
||||
|
||||
// ── Public config ─────────────────────────────────────────────────────
|
||||
url: config.url,
|
||||
|
||||
// ── QR visual state ───────────────────────────────────────────────────
|
||||
dotStyle: 'square', colorMode: 'solid',
|
||||
fgColor: '#000000',
|
||||
gradientFrom: '#4f46e5', gradientTo: '#06b6d4', gradientType: 'linear',
|
||||
bgTransparent: false, bgColor: '#ffffff',
|
||||
eyeConfigEnabled: false,
|
||||
eyeSquareStyle: 'square', eyeDotStyle: 'square', eyeColor: '#000000',
|
||||
logo: '', processedLogo: '',
|
||||
logoSize: 0.55, logoMargin: 8, logoHideBackground: true, logoShape: 'square',
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────
|
||||
_activePondFile: null,
|
||||
qrInstance: null,
|
||||
_containerPath: null,
|
||||
_fieldWatchers: [], // $wire.$watch unsubscribe callbacks
|
||||
_debounceTimer: null,
|
||||
_onFileAdd: null,
|
||||
_onFileRemove: null,
|
||||
_accordionHandler: null,
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
init() {
|
||||
this._containerPath = this._resolveContainerPath();
|
||||
|
||||
if (!this._containerPath) {
|
||||
return; // no form context — nothing to preview
|
||||
}
|
||||
|
||||
// Register one client-side $wire.$watch per design field.
|
||||
// Livewire v4's $wire.$watch is reactive and carries zero network cost.
|
||||
for (const field of QR_FIELDS) {
|
||||
const unwatch = this.$wire.$watch(
|
||||
`${this._containerPath}.${field}`,
|
||||
() => this._scheduleRender()
|
||||
);
|
||||
this._fieldWatchers.push(unwatch);
|
||||
}
|
||||
|
||||
// FilePond file-picker events (logo upload)
|
||||
const modal = this.$el.closest('.fi-modal-window');
|
||||
if (modal) {
|
||||
this._onFileAdd = (e) => {
|
||||
console.log('[QR Preview] FilePond:addfile event triggered', e.detail);
|
||||
const fileItem = e.detail?.file;
|
||||
if (!fileItem) return;
|
||||
if (fileItem.origin === 3) {
|
||||
console.log('[QR Preview] Ignoring initial server file in addfile event');
|
||||
return;
|
||||
}
|
||||
const file = fileItem.file;
|
||||
if (file) {
|
||||
console.log('[QR Preview] Found local file in event:', file);
|
||||
this._activePondFile = file;
|
||||
if (this.logo && this.logo.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.logo);
|
||||
}
|
||||
this.logo = URL.createObjectURL(file);
|
||||
this._processLogo().then(() => {
|
||||
console.log('[QR Preview] Local logo processed, rendering...');
|
||||
this.render();
|
||||
});
|
||||
} else {
|
||||
console.log('[QR Preview] No file in event, falling back to fields read...');
|
||||
const data = this._readFields();
|
||||
if (data) this._resolveLogo(data.logo_file).then(() => this.render());
|
||||
}
|
||||
};
|
||||
this._onFileRemove = () => {
|
||||
console.log('[QR Preview] FilePond:removefile event triggered');
|
||||
this._activePondFile = null;
|
||||
if (this.logo && this.logo.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.logo);
|
||||
}
|
||||
this.logo = '';
|
||||
this.processedLogo = '';
|
||||
this.render();
|
||||
};
|
||||
modal.addEventListener('FilePond:addfile', this._onFileAdd);
|
||||
modal.addEventListener('FilePond:removefile', this._onFileRemove);
|
||||
}
|
||||
|
||||
// Accordion: opening one section collapses all sibling sections.
|
||||
this._initAccordion(modal);
|
||||
|
||||
// Initial paint — deferred so Livewire has hydrated all field values.
|
||||
this.$nextTick(() => this._refresh());
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this._fieldWatchers.forEach(unwatch => unwatch?.());
|
||||
this._fieldWatchers = [];
|
||||
clearTimeout(this._debounceTimer);
|
||||
|
||||
const modal = this.$el.closest('.fi-modal-window');
|
||||
if (modal) {
|
||||
if (this._onFileAdd) modal.removeEventListener('FilePond:addfile', this._onFileAdd);
|
||||
if (this._onFileRemove) modal.removeEventListener('FilePond:removefile', this._onFileRemove);
|
||||
if (this._accordionHandler) modal.removeEventListener('click', this._accordionHandler);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the Filament schema containerPath from the nearest ancestor element
|
||||
* carrying a filamentSchemaComponent x-data declaration.
|
||||
*/
|
||||
_resolveContainerPath() {
|
||||
const ancestor = this.$el.closest('[x-data*="containerPath"]');
|
||||
if (!ancestor) return null;
|
||||
const m = (ancestor.getAttribute('x-data') ?? '').match(
|
||||
/containerPath\s*:\s*['"]([^'"]+)['"]/
|
||||
);
|
||||
return m?.[1] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Accordion behavior for the left-panel sections.
|
||||
*
|
||||
* Filament's <x-filament::section> already handles the
|
||||
* `collapse-section.window` event natively (see support section blade).
|
||||
* We detect which section was just OPENED and dispatch that event for
|
||||
* every other sibling fi-section so they auto-collapse.
|
||||
*/
|
||||
_initAccordion(container) {
|
||||
if (!container) return;
|
||||
|
||||
this._accordionHandler = (e) => {
|
||||
const header = e.target.closest('.fi-section-header');
|
||||
if (!header) return;
|
||||
|
||||
// The inner <section class="fi-section"> is where Alpine isCollapsed lives
|
||||
const clickedInner = header.closest('.fi-section');
|
||||
if (!clickedInner) return;
|
||||
|
||||
// Wait for Alpine to apply the fi-collapsed class
|
||||
this.$nextTick(() => {
|
||||
// Only collapse siblings when this section just OPENED (not collapsed)
|
||||
if (clickedInner.classList.contains('fi-collapsed')) return;
|
||||
|
||||
// Walk up to outer wrapper: div.fi-sc-section — that has the ID
|
||||
// that Filament uses as collapseId in x-on:collapse-section.window
|
||||
container.querySelectorAll('.fi-sc-section').forEach(outerWrapper => {
|
||||
const innerSection = outerWrapper.querySelector(':scope > .fi-section, :scope > x-filament\.section > .fi-section, .fi-section');
|
||||
if (!innerSection || innerSection === clickedInner) return;
|
||||
if (innerSection.classList.contains('fi-collapsed')) return; // already collapsed
|
||||
|
||||
// collapseId matches the outer wrapper's id attribute
|
||||
const collapseId = outerWrapper.id;
|
||||
if (collapseId) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('collapse-section', { detail: { id: collapseId } })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
container.addEventListener('click', this._accordionHandler);
|
||||
},
|
||||
|
||||
/** Coalesce rapid multi-field changes into a single render. */
|
||||
_scheduleRender() {
|
||||
clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = setTimeout(() => this._refresh(), 50);
|
||||
},
|
||||
|
||||
_refresh() {
|
||||
if (!this.$el?.isConnected) return;
|
||||
const data = this._readFields();
|
||||
if (data) this.applyActionData(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Read every design field via individual $wire.$get leaf-path calls.
|
||||
*
|
||||
* Livewire v4's $wire proxy resolves scalar leaf values correctly, but does
|
||||
* not expose the container object itself through $get. Reading fields one by
|
||||
* one is therefore the canonical approach for nested action form data.
|
||||
*
|
||||
* "dot_style" acts as the mount probe: undefined means the action form has
|
||||
* not yet been fully hydrated by Livewire.
|
||||
*/
|
||||
_readFields() {
|
||||
const path = this._containerPath;
|
||||
if (!path) return null;
|
||||
|
||||
const g = (field) => {
|
||||
try { return this.$wire.$get(`${path}.${field}`); } catch { return undefined; }
|
||||
};
|
||||
|
||||
const dotStyle = g('dot_style');
|
||||
if (dotStyle === undefined) return null; // form not ready yet
|
||||
|
||||
return {
|
||||
dot_style: dotStyle,
|
||||
color_mode: g('color_mode'),
|
||||
foreground_color: g('foreground_color'),
|
||||
gradient_from: g('gradient_from'),
|
||||
gradient_to: g('gradient_to'),
|
||||
gradient_type: g('gradient_type'),
|
||||
bg_transparent: g('bg_transparent'),
|
||||
background_color: g('background_color'),
|
||||
eye_config_enabled: g('eye_config_enabled'),
|
||||
eye_square_style: g('eye_square_style'),
|
||||
eye_dot_style: g('eye_dot_style'),
|
||||
eye_color: g('eye_color'),
|
||||
logo_shape: g('logo_shape'),
|
||||
logo_size: g('logo_size'),
|
||||
logo_margin: g('logo_margin'),
|
||||
logo_hide_background: g('logo_hide_background'),
|
||||
logo_file: g('logo_file'),
|
||||
};
|
||||
},
|
||||
|
||||
// ── State application ─────────────────────────────────────────────────
|
||||
|
||||
applyActionData(data) {
|
||||
this.dotStyle = data.dot_style ?? 'square';
|
||||
this.colorMode = data.color_mode ?? 'solid';
|
||||
this.fgColor = data.foreground_color ?? '#000000';
|
||||
this.gradientFrom = data.gradient_from ?? '#4f46e5';
|
||||
this.gradientTo = data.gradient_to ?? '#06b6d4';
|
||||
this.gradientType = data.gradient_type ?? 'linear';
|
||||
this.bgTransparent = !!data.bg_transparent;
|
||||
this.bgColor = data.background_color ?? '#ffffff';
|
||||
this.eyeConfigEnabled = !!data.eye_config_enabled;
|
||||
this.eyeSquareStyle = data.eye_square_style ?? 'square';
|
||||
this.eyeDotStyle = data.eye_dot_style ?? 'square';
|
||||
this.eyeColor = data.eye_color ?? '#000000';
|
||||
this.logoShape = data.logo_shape ?? 'square';
|
||||
this.logoSize = parseFloat(data.logo_size) || 0.55;
|
||||
this.logoMargin = parseFloat(data.logo_margin) || 8;
|
||||
this.logoHideBackground = true;
|
||||
|
||||
this._resolveLogo(data.logo_file).then(() => this.render());
|
||||
},
|
||||
|
||||
_resolveLogo(logoFile) {
|
||||
console.log('[QR Preview] Resolving logo with:', logoFile);
|
||||
|
||||
if (this._activePondFile) {
|
||||
console.log('[QR Preview] Using active FilePond file:', this._activePondFile);
|
||||
if (!this.logo || !this.logo.startsWith('blob:')) {
|
||||
this.logo = URL.createObjectURL(this._activePondFile);
|
||||
}
|
||||
return this._processLogo();
|
||||
}
|
||||
|
||||
let logoPath = null;
|
||||
if (typeof logoFile === 'string') {
|
||||
logoPath = logoFile;
|
||||
} else if (Array.isArray(logoFile)) {
|
||||
logoPath = logoFile[0];
|
||||
} else if (logoFile && typeof logoFile === 'object') {
|
||||
const vals = Object.values(logoFile);
|
||||
if (vals.length > 0) {
|
||||
logoPath = vals[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (logoPath && typeof logoPath === 'string') {
|
||||
if (this.logo && this.logo.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.logo);
|
||||
}
|
||||
this.logo = /^(https?:|data:)/.test(logoPath)
|
||||
? logoPath
|
||||
: `/short-url/logo/${logoPath.split('/').pop()}`;
|
||||
console.log('[QR Preview] Resolved logo URL from path:', this.logo);
|
||||
return this._processLogo();
|
||||
}
|
||||
|
||||
console.log('[QR Preview] No logo file to resolve.');
|
||||
if (this.logo && this.logo.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.logo);
|
||||
}
|
||||
this.logo = '';
|
||||
this.processedLogo = '';
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
_processLogo() {
|
||||
console.log('[QR Preview] Processing logo image:', this.logo);
|
||||
return window.QrHelper
|
||||
.processLogo(this.logo, this.logoShape, this.logoMargin)
|
||||
.then(result => {
|
||||
this.processedLogo = result;
|
||||
console.log('[QR Preview] Processed logo data URI length:', result ? result.length : 0);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
buildOptions() {
|
||||
return window.QrHelper.buildOptions(this, this.url, 280);
|
||||
},
|
||||
|
||||
render() {
|
||||
const canvas = this.$refs.qrCanvas;
|
||||
if (!canvas) {
|
||||
console.log('[QR Preview] Canvas ref not found!');
|
||||
return;
|
||||
}
|
||||
console.log('[QR Preview] Rendering QR with options:', this.buildOptions());
|
||||
window.QrHelper.loadScript().then(() => {
|
||||
this.qrInstance = window.QrHelper.render(canvas, this.buildOptions(), true);
|
||||
});
|
||||
},
|
||||
|
||||
download(extension) {
|
||||
const exportOpts = window.QrHelper.buildOptions(this, this.url, 2000);
|
||||
|
||||
// Render to a temporary container to apply our custom post-processing (like circular clip-path)
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
window.QrHelper.loadScript().then(() => {
|
||||
const qr = window.QrHelper.render(tempDiv, exportOpts, true);
|
||||
if (!qr) {
|
||||
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
|
||||
return;
|
||||
}
|
||||
|
||||
qr.drawingPromise.then(() => {
|
||||
const svg = tempDiv.querySelector('svg');
|
||||
if (!svg) {
|
||||
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension === 'svg') {
|
||||
const svgString = new XMLSerializer().serializeToString(svg);
|
||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = svgUrl;
|
||||
a.download = 'qr-code.svg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
|
||||
} else {
|
||||
// For PNG, draw the modified SVG to a canvas
|
||||
const svgString = new XMLSerializer().serializeToString(svg);
|
||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2000;
|
||||
canvas.height = 2000;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
const pngUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = pngUrl;
|
||||
a.download = 'qr-code.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
if (tempDiv.parentNode) document.body.removeChild(tempDiv);
|
||||
}, 'image/png');
|
||||
};
|
||||
img.src = svgUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
}));
|
||||
});
|
||||
294
resources/dist/meta-scraper.js
vendored
Normal file
@@ -0,0 +1,294 @@
|
||||
window.addEventListener('error', function (e) {
|
||||
var errorData = {
|
||||
message: e.message,
|
||||
file: e.filename,
|
||||
line: e.lineno,
|
||||
col: e.colno,
|
||||
stack: e.error ? e.error.stack : ''
|
||||
};
|
||||
fetch('/short-url/log-error', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
},
|
||||
body: JSON.stringify(errorData)
|
||||
}).catch(function () {});
|
||||
});
|
||||
|
||||
window.fsuDispatchScraping = function (isScraping) {
|
||||
window.dispatchEvent(new CustomEvent(isScraping ? 'fsu-scraping-start' : 'fsu-scraping-end'));
|
||||
};
|
||||
|
||||
window.fsuResolveFormApi = function ($get, $set, $el) {
|
||||
if (typeof $get === 'function' && typeof $set === 'function') {
|
||||
return {
|
||||
get: $get,
|
||||
set: function (path, value) {
|
||||
$set(path, value, false, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
var host = $el || null;
|
||||
|
||||
if (host && typeof host.closest === 'function') {
|
||||
var componentEl = host.closest('[x-data*="filamentSchemaComponent"]');
|
||||
|
||||
if (componentEl && typeof Alpine !== 'undefined') {
|
||||
var data = Alpine.$data(componentEl);
|
||||
|
||||
if (data && typeof data.$get === 'function' && typeof data.$set === 'function') {
|
||||
return {
|
||||
get: data.$get.bind(data),
|
||||
set: function (path, value) {
|
||||
data.$set(path, value, false, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
window.fsuIsPasswordProtected = function (api) {
|
||||
if (!api || typeof api.get !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(api.get('password_active_flag') || api.get('password'));
|
||||
};
|
||||
|
||||
window.fsuHasManualOgImage = function (get) {
|
||||
if (typeof get !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var image = get('og_image');
|
||||
|
||||
if (!image) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof image === 'string') {
|
||||
return image.trim() !== '';
|
||||
}
|
||||
|
||||
if (Array.isArray(image)) {
|
||||
return image.length > 0;
|
||||
}
|
||||
|
||||
if (typeof image === 'object') {
|
||||
return Object.keys(image).length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
window.fsuLockScrape = function (inputEl) {
|
||||
if (inputEl) {
|
||||
inputEl.dataset.fsuScrapeLocked = 'true';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
|
||||
el.dataset.fsuScrapeLocked = 'true';
|
||||
});
|
||||
};
|
||||
|
||||
window.fsuUnlockScrape = function (inputEl) {
|
||||
if (inputEl) {
|
||||
delete inputEl.dataset.fsuScrapeLocked;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
|
||||
delete el.dataset.fsuScrapeLocked;
|
||||
});
|
||||
};
|
||||
|
||||
window.fsuIsScrapeLocked = function (inputEl, get) {
|
||||
if (inputEl && inputEl.dataset.fsuScrapeLocked === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return window.fsuHasManualOgImage(get);
|
||||
};
|
||||
|
||||
window.fsuGetScrapedUrl = function (inputEl) {
|
||||
return inputEl && inputEl.dataset.scrapedUrl ? inputEl.dataset.scrapedUrl : null;
|
||||
};
|
||||
|
||||
window.fsuMarkDestinationScraped = function (inputEl, url) {
|
||||
if (inputEl && url) {
|
||||
inputEl.dataset.scrapedUrl = url;
|
||||
}
|
||||
};
|
||||
|
||||
window.fsuClearDestinationScrape = function (inputEl) {
|
||||
if (inputEl) {
|
||||
delete inputEl.dataset.scrapedUrl;
|
||||
}
|
||||
};
|
||||
|
||||
window.fsuShouldSkipScrape = function (inputEl, url, get) {
|
||||
if (window.fsuIsScrapeLocked(inputEl, get)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return window.fsuGetScrapedUrl(inputEl) === url;
|
||||
};
|
||||
|
||||
window.fsuStopScraping = function (api) {
|
||||
window.fsuDispatchScraping(false);
|
||||
window.dispatchEvent(new CustomEvent('fsu-og-image-updated'));
|
||||
|
||||
if (api && typeof api.set === 'function') {
|
||||
api.set('is_scraping', false);
|
||||
}
|
||||
};
|
||||
|
||||
window.fsuOnManualOgImage = function ($get, $set) {
|
||||
var api = window.fsuResolveFormApi($get, $set);
|
||||
|
||||
if (api) {
|
||||
api.set('og_image_scraped', null);
|
||||
}
|
||||
|
||||
window.fsuStopScraping(api);
|
||||
window.fsuLockScrape();
|
||||
};
|
||||
|
||||
window.fsuClearOgMetadata = function (api) {
|
||||
if (!api || typeof api.set !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
api.set('og_title', null);
|
||||
api.set('og_description', null);
|
||||
api.set('og_image_scraped', null);
|
||||
};
|
||||
|
||||
window.fsuRetryScrapeAfterImageRemoved = function ($get, $set) {
|
||||
var api = window.fsuResolveFormApi($get, $set);
|
||||
var dest = api ? api.get('destination_url') : null;
|
||||
|
||||
if (!dest || window.fsuHasManualOgImage(api ? api.get : $get)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.fsuUnlockScrape();
|
||||
window.fsuClearOgMetadata(api);
|
||||
|
||||
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
|
||||
window.fsuClearDestinationScrape(el);
|
||||
window.fsuScrape(dest, $get, $set, el);
|
||||
});
|
||||
};
|
||||
|
||||
window.fsuScrape = function (val, $get, $set, $el) {
|
||||
if (!val) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var url = new URL(val);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
var api = window.fsuResolveFormApi($get, $set, $el);
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.fsuIsPasswordProtected(api)) {
|
||||
window.fsuStopScraping(api);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.fsuShouldSkipScrape($el, val, api.get)) {
|
||||
window.fsuStopScraping(api);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.fsuDispatchScraping(true);
|
||||
api.set('is_scraping', true);
|
||||
|
||||
fetch('/short-url/scrape-meta?url=' + encodeURIComponent(val))
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (data) {
|
||||
if (window.fsuIsPasswordProtected(api)) {
|
||||
window.fsuStopScraping(api);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.fsuIsScrapeLocked($el, api.get)) {
|
||||
window.fsuStopScraping(api);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && (data.title || data.description || data.image)) {
|
||||
if (data.title && !api.get('og_title')) {
|
||||
api.set('og_title', data.title);
|
||||
}
|
||||
if (data.description && !api.get('og_description')) {
|
||||
api.set('og_description', data.description);
|
||||
}
|
||||
if (data.image && !window.fsuHasManualOgImage(api.get)) {
|
||||
api.set('og_image_scraped', data.image);
|
||||
window.fsuLockScrape($el);
|
||||
} else {
|
||||
window.fsuStopScraping(api);
|
||||
}
|
||||
|
||||
window.fsuMarkDestinationScraped($el, val);
|
||||
} else {
|
||||
window.fsuStopScraping(api);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
window.fsuStopScraping(api);
|
||||
});
|
||||
};
|
||||
|
||||
window.fsuInitScrape = function ($get, $el) {
|
||||
var api = window.fsuResolveFormApi($get, null, $el);
|
||||
|
||||
if (!$el || !api) {
|
||||
return;
|
||||
}
|
||||
|
||||
var dest = api.get('destination_url');
|
||||
|
||||
if (!dest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.fsuHasManualOgImage(api.get)) {
|
||||
window.fsuLockScrape($el);
|
||||
window.fsuMarkDestinationScraped($el, dest);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!window.fsuGetScrapedUrl($el)
|
||||
&& (api.get('og_title') || api.get('og_description') || api.get('og_image_scraped'))
|
||||
) {
|
||||
window.fsuMarkDestinationScraped($el, dest);
|
||||
}
|
||||
};
|
||||
2
resources/dist/qr-code-styling.js
vendored
Normal file
@@ -13,7 +13,7 @@ return [
|
||||
|
||||
// Form Tabs
|
||||
'tab_link' => 'Link Details',
|
||||
'tab_tracking' => 'Tracking Settings',
|
||||
'tab_tracking' => 'Tracking',
|
||||
'tab_qr_design' => 'QR Code Design',
|
||||
'tab_app_linking' => 'App Linking',
|
||||
|
||||
@@ -46,10 +46,68 @@ return [
|
||||
'activated_at' => 'Active From',
|
||||
'max_visits' => 'Max Visits Limit',
|
||||
'max_visits_helper' => 'Optional limit of total visits allowed before this link is deactivated.',
|
||||
'max_visits_no_limit' => 'No limit',
|
||||
'max_visits_suffix' => 'visits',
|
||||
'number_stepper_decrease' => 'Decrease value',
|
||||
'number_stepper_increase' => 'Increase value',
|
||||
'expiration_redirect_url' => 'Expiration Redirect URL',
|
||||
'expiration_redirect_url_helper' => 'Fallback URL to redirect to when the link is expired or inactive (leaves blank for 410 Gone error page).',
|
||||
'form_section_validity' => 'Validity & Limits',
|
||||
'use_date_validity' => 'Set Date Range Limits',
|
||||
'use_date_validity_helper' => 'Schedule when the link becomes active and when it should stop working.',
|
||||
'validity_tab_intro' => 'Control when your link is available and how many times it can be clicked. Leave everything off for a link that stays open indefinitely.',
|
||||
'validity_schedule_card_title' => 'Schedule availability',
|
||||
'validity_schedule_card_subtitle' => 'Optional start and end dates for campaigns, launches, or limited-time offers.',
|
||||
'validity_schedule_empty_title' => 'Always available',
|
||||
'validity_schedule_empty_desc' => 'Turn on the switch above to set an activation date, expiration date, or both.',
|
||||
'validity_schedule_timeline_label' => 'Active window',
|
||||
'validity_activated_at_helper' => 'Leave empty to activate immediately when saved.',
|
||||
'validity_expires_at_helper' => 'Optional. After this moment the link stops redirecting.',
|
||||
'validity_single_use_toggle' => 'Enable single-use mode',
|
||||
'validity_single_use_active_note' => 'Single-use is on — the link deactivates after the first successful visit. Max visits limit is ignored.',
|
||||
'validity_max_visits_locked' => 'Unavailable while single-use is enabled',
|
||||
'tracking_visit_card_title' => 'Visit analytics',
|
||||
'tracking_visit_card_subtitle' => 'Collect click statistics for this link — disable for privacy-first or redirect-only links.',
|
||||
'tracking_visit_empty_title' => 'Tracking disabled',
|
||||
'tracking_visit_empty_desc' => 'Turn on the switch above to record visits and configure which data points are stored.',
|
||||
'tracking_fields_identity_title' => 'Identity & source',
|
||||
'tracking_fields_device_title' => 'Device & browser',
|
||||
'track_ip_desc' => 'IP address for geo insights and abuse signals.',
|
||||
'track_referer_desc' => 'Referring page URL when the visitor arrives from another site.',
|
||||
'track_browser_desc' => 'Browser family such as Chrome, Safari, or Firefox.',
|
||||
'track_browser_version_desc' => 'Exact browser version string from the user agent.',
|
||||
'track_os_desc' => 'Operating system name such as iOS, Windows, or Android.',
|
||||
'track_os_version_desc' => 'OS version reported by the visitor device.',
|
||||
'track_device_type_desc' => 'Device category — desktop, mobile, tablet, or bot.',
|
||||
'track_browser_language_desc' => 'Preferred language from the Accept-Language header.',
|
||||
'tracking_utm_card_title' => 'Campaign parameters',
|
||||
'tracking_utm_card_subtitle' => 'Append UTM tags to the destination URL. Changes sync instantly with the link target.',
|
||||
'tracking_ga_card_title' => 'Google Analytics 4',
|
||||
'tracking_ga_card_subtitle' => 'Optional Measurement ID for server-side redirect events via GA4 Measurement Protocol.',
|
||||
'link_destination_card_title' => 'Destination',
|
||||
'link_destination_card_subtitle' => 'Send visitors to one URL or split traffic across A/B test variants.',
|
||||
'link_short_url_card_title' => 'Short link',
|
||||
'link_short_url_card_subtitle' => 'Choose a domain and customize the back-half of your short URL.',
|
||||
'link_behavior_card_title' => 'Behavior',
|
||||
'link_behavior_card_subtitle' => 'Control whether the link is active and how incoming query parameters are handled.',
|
||||
'link_status_desc' => 'When disabled, visitors see an error page instead of being redirected.',
|
||||
'link_tags_card_title' => 'Tags',
|
||||
'link_tags_card_subtitle' => 'Add up to five labels to organize, filter, and report on links.',
|
||||
'link_notes_card_title' => 'Internal notes',
|
||||
'link_notes_card_subtitle' => 'Private admin memo — never shown to visitors.',
|
||||
'link_notes_placeholder' => 'Add a reminder for your team…',
|
||||
'targeting_rules_card_title' => 'Conditional redirects',
|
||||
'targeting_rules_card_subtitle' => 'Send visitors to different URLs based on device, country, language, or platform.',
|
||||
'targeting_rules_empty_title' => 'No targeting rules yet',
|
||||
'targeting_rules_empty_desc' => 'Add a rule below to override the default destination when specific conditions match.',
|
||||
'add_targeting_rule' => 'Add rule',
|
||||
'targeting_rule_conditions_title' => 'When to apply',
|
||||
'targeting_rule_destination_title' => 'Redirect to',
|
||||
'filter_duplicate_error' => 'Each filter type (Device, Platform, Country, Language) can only be added once.',
|
||||
'app_linking_card_title' => 'App linking',
|
||||
'app_linking_card_subtitle' => 'Open the native mobile app instead of the browser when a matching app is detected.',
|
||||
'app_linking_empty_title' => 'App auto-open disabled',
|
||||
'app_linking_empty_desc' => 'Turn on the switch above to preview and enable deep-link behavior on mobile devices.',
|
||||
'notes' => 'Internal Notes',
|
||||
'ga_tracking_id' => 'Google Analytics 4 Measurement ID',
|
||||
'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.',
|
||||
@@ -112,8 +170,10 @@ return [
|
||||
'qr_label_eye_dot_style' => 'Eye Dot Style',
|
||||
'qr_label_eye_color' => 'Eye Color',
|
||||
'qr_label_preview' => 'Preview',
|
||||
'qr_option_none' => 'None',
|
||||
'qr_option_square' => 'Square',
|
||||
'qr_option_dots' => 'Dots',
|
||||
'qr_option_dot' => 'Dot',
|
||||
'qr_option_rounded' => 'Rounded',
|
||||
'qr_option_classy' => 'Classy',
|
||||
'qr_option_classy_rounded' => 'Classy Rounded',
|
||||
@@ -138,6 +198,7 @@ return [
|
||||
'qr_option_circle' => 'Circle',
|
||||
'qr_label_png' => 'PNG',
|
||||
'qr_label_svg' => 'SVG',
|
||||
'qr_save_design' => 'Save Design',
|
||||
|
||||
// Table Columns
|
||||
'col_short_url' => 'Short URL',
|
||||
@@ -234,7 +295,39 @@ return [
|
||||
'settings_cache_ttl' => 'Redirect Cache TTL',
|
||||
'settings_cache_ttl_helper' => 'Seconds to cache resolved short URL records. Set to 0 to disable (not recommended in production).',
|
||||
'settings_queue_connection' => 'Queue Connection',
|
||||
'settings_queue_connection_helper' => 'Laravel queue connection used for async visit tracking. Use "sync" for synchronous (slower redirects), or "redis"/"sqs" for production.',
|
||||
'settings_queue_connection_helper' => 'Laravel queue connection for async visit tracking. Each driver has different requirements — see the info box below when you change this value.',
|
||||
|
||||
'settings_redis_test' => 'Test Redis connection',
|
||||
'settings_redis_test_ok' => 'Redis connection OK',
|
||||
'settings_redis_test_fail' => 'Redis connection failed',
|
||||
'settings_queue_worker_test' => 'Test queue worker',
|
||||
'settings_queue_worker_test_ok' => 'Queue worker OK',
|
||||
'settings_queue_worker_test_fail' => 'Queue worker not responding',
|
||||
'settings_section_redis' => 'Redis Connection',
|
||||
'settings_section_redis_description' => 'These values override database.redis and queue.connections.redis at runtime when Queue Connection is redis. Save settings after changing them.',
|
||||
'settings_redis_host' => 'Redis Host',
|
||||
'settings_redis_host_helper' => 'Hostname or IP of the Redis server used for queues, counters, stats, and live feed.',
|
||||
'settings_redis_port' => 'Redis Port',
|
||||
'settings_redis_port_helper' => 'Default: 6379.',
|
||||
'settings_redis_password' => 'Redis Password',
|
||||
'settings_redis_password_helper' => 'Leave empty if Redis has no password. Stored securely in settings.',
|
||||
'settings_redis_database' => 'Redis Database',
|
||||
'settings_redis_database_helper' => 'Logical Redis DB index (0–15). Default: 0.',
|
||||
'settings_redis_key_prefix' => 'Key Prefix',
|
||||
'settings_redis_key_prefix_helper' => 'Optional prefix for all plugin Redis keys. Leave empty to use config default.',
|
||||
|
||||
'settings_queue_worker_command' => '<strong>Queue Worker Required:</strong> Run a worker on the same connection and queue name as above (independent of <code>QUEUE_CONNECTION</code> in <code>.env</code>): <code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code>',
|
||||
|
||||
'settings_queue_mode_desc_redis' => '<strong>redis</strong> — production mode: async jobs, dedicated Redis counters/stats/live feed (phpredis or Predis), auto counter buffering. Configure Redis below (overrides .env at runtime). Use the test buttons before saving.',
|
||||
'settings_queue_mode_desc_database' => '<strong>database</strong> — async visit jobs stored in the <code>jobs</code> table. No dedicated Redis counters unless you enable buffering manually.',
|
||||
'settings_queue_mode_desc_sqs' => '<strong>sqs</strong> — async jobs via AWS SQS. Requires valid AWS credentials in <code>config/queue.php</code>.',
|
||||
'settings_queue_mode_desc_beanstalkd' => '<strong>beanstalkd</strong> — async jobs via Beanstalkd. Requires a running beanstalkd server.',
|
||||
'settings_queue_mode_desc_deferred' => '<strong>deferred</strong> — jobs run after the HTTP response in the same PHP process. No separate worker, but adds post-response latency under load.',
|
||||
'settings_queue_mode_desc_background' => '<strong>background</strong> — Laravel background driver: jobs run after the response without a queue worker (similar to deferred).',
|
||||
'settings_queue_mode_desc_failover' => '<strong>failover</strong> — uses your Laravel failover chain in <code>config/queue.php</code>. Ensure at least one backend in the chain is reachable.',
|
||||
'settings_queue_mode_desc_default' => '<strong>:connection</strong> — async visit tracking via this Laravel queue connection. Ensure it is configured in <code>config/queue.php</code>.',
|
||||
|
||||
'settings_queue_mode_info_sync' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="text-sm text-neutral-800 dark:text-neutral-300"><strong>sync</strong> — visits are recorded during the redirect (no queue worker). Best for local dev and low traffic. Optional manual counter buffering via the toggle below.</div></div>',
|
||||
|
||||
'settings_geoip_enabled' => 'Enable Geo-IP Detection',
|
||||
'settings_geoip_enabled_helper' => 'Detect and record the visitor\'s country on each visit.',
|
||||
@@ -263,11 +356,15 @@ return [
|
||||
'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_measurement_id' => 'Measurement ID for connection test',
|
||||
'settings_ga4_verify_measurement_id_helper' => 'Use the exact G-XXXXXXXXXX from the same GA4 data stream as your API secret. Required unless you use Firebase App ID.',
|
||||
'settings_ga4_verify_measurement_required' => 'Enter a Measurement ID (G-XXXXXXXXXX) or Firebase App ID to test the connection.',
|
||||
'settings_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
|
||||
|
||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
||||
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
|
||||
'settings_buffering_redis_auto' => 'Automatically enabled with Redis queue. Visit counters, today stats, and live feed use the dedicated queue Redis connection (phpredis or Predis) — independent of CACHE_STORE. Schedule short-url:sync-counters every minute.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
@@ -275,10 +372,12 @@ return [
|
||||
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
|
||||
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Warning (IP Address Security):</strong> You have enabled trust for proxy headers. Enable this option <u>only</u> if your website is behind:<br>• <strong>Cloudflare</strong> (reads from CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> or another CDN system<br>• <strong>Nginx/Apache reverse proxy</strong> forwarding X-Forwarded-For.<br><br><strong>Turn this option OFF</strong> if your server connects directly to users. If left ON without a proxy, malicious users can easily spoof their IP address by sending a custom header.</span></div></div>',
|
||||
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Proxy Header Trust Disabled:</strong> You selected <em>CDN Headers</em> as the Geo-IP driver, but the option <strong>"Trust CDN & Proxy Headers"</strong> in the <em>General</em> tab is disabled. Location detection will not function until you enable it.</span></div></div>',
|
||||
'settings_geoip_headers_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-950/20" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-blue-800 dark:text-blue-300" aria-label="Info"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-blue-900 dark:text-blue-200" data-component-part="callout-content"><span><strong>CDN Headers driver:</strong> Saving settings with this driver automatically enables <strong>Trust CDN & Proxy Headers</strong>. When CDN country headers are missing, the plugin falls back to MaxMind and then ip-api.com (rate-limited) instead of returning empty geo data.</span></div></div>',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Visited From',
|
||||
'stats_filter_visited_until' => 'Visited Until',
|
||||
'stats_filter_counted_in_stats' => 'Stats-counted visits only',
|
||||
'stats_action_export' => 'Export CSV',
|
||||
'stats_csv_time' => 'Time',
|
||||
'stats_csv_ip' => 'IP Address',
|
||||
@@ -305,8 +404,11 @@ return [
|
||||
'form_section_options' => 'Options',
|
||||
'form_section_notes' => 'Internal Notes',
|
||||
'form_section_tracking' => 'Visit Tracking',
|
||||
'form_section_tracking_desc' => 'Enable or disable visit tracking and statistics for this short link.',
|
||||
'form_section_tracked_fields' => 'Tracked Fields',
|
||||
'form_section_tracked_fields_desc' => 'Choose which metrics are collected for this link — useful for analytics depth and privacy.',
|
||||
'form_section_analytics' => 'Third-Party Analytics',
|
||||
'form_section_analytics_desc' => 'Integrate this link with Google Analytics by providing a data stream identifier.',
|
||||
|
||||
// New Dashboard Analytics Keys
|
||||
'stats_card_top_source' => 'Top UTM Source',
|
||||
@@ -327,15 +429,35 @@ return [
|
||||
'stats_no_utm_data' => 'No UTM data recorded.',
|
||||
|
||||
// New Targeting & Security Tab and fields
|
||||
'tab_targeting' => 'Targeting & Security',
|
||||
'tab_targeting' => 'Targeting',
|
||||
'tab_password' => 'Password',
|
||||
'tab_validity' => 'Expiration',
|
||||
'expiration_dates_section_title' => 'Date & Time Expiration',
|
||||
'visit_limits_section_title' => 'Visit Limits',
|
||||
'security_section_title' => 'Security Controls',
|
||||
'password' => 'Access Password',
|
||||
'password_helper' => 'Require visitors to enter a password before being redirected.',
|
||||
'password_card_title' => 'Password protection',
|
||||
'password_card_subtitle' => 'Require visitors to enter a password before being redirected.',
|
||||
'warning_page_card_title' => 'Redirect warning page',
|
||||
'warning_page_card_subtitle' => 'Show an intermediate screen before redirecting visitors to the destination.',
|
||||
'warning_page_empty_title' => 'Warning page disabled',
|
||||
'warning_page_empty_desc' => 'Turn on the switch above to show a safety screen before external redirects.',
|
||||
'warning_page_active_title' => 'Warning page enabled',
|
||||
'confirm_password' => 'Confirm Password',
|
||||
'new_password' => 'New Password',
|
||||
'change_password' => 'Change Password',
|
||||
'remove_password' => 'Remove Password',
|
||||
'password_status_active' => 'Password protection is enabled.',
|
||||
'password_status_active_desc' => 'This link is protected. Visitors must enter the correct password to continue.',
|
||||
'password_empty_state_title' => 'No protection',
|
||||
'password_empty_state_desc' => 'Add a password to restrict access to this short link to selected people only.',
|
||||
'password_settings_section' => 'Password settings',
|
||||
'password_change_short' => 'Change',
|
||||
'password_remove_short' => 'Remove',
|
||||
'save_password' => 'Save password',
|
||||
'expiration_dates_section_desc' => 'Control when the link is available. Schedule a campaign start or automatic end.',
|
||||
'visit_limits_section_desc' => 'Cap total clicks. Useful for one-time tickets or limited offers.',
|
||||
'set_password' => 'Set Password',
|
||||
'cancel' => 'Cancel',
|
||||
'confirm' => 'Confirm',
|
||||
@@ -367,6 +489,7 @@ return [
|
||||
'variant_url' => 'Variant URL',
|
||||
'variant_weight' => 'Traffic Share (%)',
|
||||
'safe_browsing_error' => 'This URL has been flagged by Google Safe Browsing as unsafe.',
|
||||
'outbound_url_blocked' => 'This outbound URL is not allowed.',
|
||||
|
||||
// New Advanced Targeting Builder
|
||||
'targeting_rules' => 'Targeting Rules',
|
||||
@@ -392,8 +515,8 @@ return [
|
||||
'settings_section_aggregation' => 'High-Traffic Log Management',
|
||||
'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.',
|
||||
'settings_aggregation_enabled' => 'Enable raw visit log pruning',
|
||||
'settings_aggregation_enabled_helper' => 'The daily 02:00 job always aggregates visits into daily statistics. This toggle controls only whether raw visit rows older than the retention period are deleted.',
|
||||
'retention_30_days' => '30 Days',
|
||||
'retention_60_days' => '60 Days',
|
||||
'retention_90_days' => '90 Days',
|
||||
@@ -403,6 +526,7 @@ return [
|
||||
'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.',
|
||||
'settings_rate_limiting_route_info' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200"><strong>Two layers:</strong> redirect routes also use the package middleware throttle (default <code>120/min</code> per IP). When you enable the limiter below, <em>both</em> can return HTTP 429 — the stricter one wins first.</div>',
|
||||
'settings_rate_limiting_max_attempts' => 'Max Redirects Allowed',
|
||||
'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
|
||||
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
|
||||
@@ -411,7 +535,7 @@ return [
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Queue Name',
|
||||
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. Run a worker that listens on the same connection and queue name as above (this is independent of <code>QUEUE_CONNECTION</code> in <code>.env</code>):<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Time (in seconds) to cache stats calculation on the dashboard. When viewing charts and logs, the system loads calculated numbers from cache instead of querying millions of rows from SQL on every refresh. Default: 300 (5 minutes). Max: 86400 (24 hours).',
|
||||
|
||||
@@ -463,14 +587,22 @@ return [
|
||||
'tab_marketing' => 'Marketing & API',
|
||||
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
|
||||
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
|
||||
'marketing_pixels_empty_title' => 'No pixels attached',
|
||||
'marketing_pixels_empty_desc' => 'Select one or more retargeting pixels to fire client-side tracking scripts when visitors click this link.',
|
||||
'marketing_pixels_select_placeholder' => 'Choose retargeting pixels…',
|
||||
'marketing_webhook_empty_title' => 'No webhook configured',
|
||||
'marketing_webhook_empty_desc' => 'Add a destination URL to receive real-time HTTP POST notifications on each click.',
|
||||
'pixel_meta' => 'Meta Pixel ID',
|
||||
'pixel_google' => 'Google Tag / GA4 ID',
|
||||
'pixel_linkedin' => 'LinkedIn Partner ID',
|
||||
'marketing_webhooks_title' => 'Link Webhook Integration',
|
||||
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
|
||||
'webhook_url' => 'Dedicated Webhook URL',
|
||||
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).',
|
||||
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (URL key, destination, device, browser, operating system, geo, referrer, UTM parameters, and QR scan flag). IP addresses are not included in webhook payloads.',
|
||||
'webhook_show_payload' => 'Show example JSON payload',
|
||||
'webhook_payload_modal_title' => 'Example webhook payload',
|
||||
'webhook_payload_modal_desc' => 'Sample JSON body for the visited event. Actual values depend on each click.',
|
||||
'webhook_payload_copy' => 'Copy payload to clipboard',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API & Webhooks',
|
||||
@@ -492,6 +624,8 @@ return [
|
||||
'settings_api_keys_description' => 'Manage API keys to integrate with external systems (e.g. CRM, Zapier, Make). Authenticate requests using the X-Api-Key header.',
|
||||
'settings_api_keys' => 'Active API Keys',
|
||||
'api_key_name' => 'Key Name (Description)',
|
||||
'api_key_owner_user_id' => 'Owner User ID',
|
||||
'api_key_owner_user_id_helper' => 'Required when link scoping is enabled (default). Limits this key to links owned by that user.',
|
||||
'api_key' => 'API Key',
|
||||
'active' => 'Active',
|
||||
'api_key_generated' => 'API Key Generated',
|
||||
@@ -500,6 +634,16 @@ return [
|
||||
// Security v2.0
|
||||
'settings_section_security_v2' => 'Security & Anti-Fraud v2.0',
|
||||
'settings_section_security_v2_desc' => 'Configure VPN/proxy detection and Google Safe Browsing URL validation.',
|
||||
'settings_section_analytics_security' => 'Analytics & Bot Detection',
|
||||
'settings_section_analytics_security_desc' => 'Optional click deduplication and advanced bot detection settings.',
|
||||
'settings_click_dedup_enabled' => 'Enable Click Deduplication',
|
||||
'settings_click_dedup_enabled_helper' => 'Ignore repeat clicks from the same IP within the configured time window.',
|
||||
'settings_click_dedup_hours' => 'Deduplication Window (hours)',
|
||||
'settings_click_dedup_hours_helper' => 'How long to treat repeat clicks from the same IP as duplicates.',
|
||||
'settings_bot_verify_googlebot' => 'Verify Googlebot IP',
|
||||
'settings_bot_verify_googlebot_helper' => 'Reverse-DNS + forward IP check for Googlebot user agents.',
|
||||
'settings_bot_debug_secret' => 'Bot Debug Secret',
|
||||
'settings_bot_debug_secret_helper' => 'Required value for ?bot=1 outside local/testing environments.',
|
||||
'settings_vpn_detection_enabled' => 'Enable VPN & Proxy Detection',
|
||||
'settings_vpn_detection_enabled_helper' => 'When enabled, incoming visits will be checked for VPN, proxy, or Tor usage.',
|
||||
'settings_vpn_driver' => 'Detection Driver',
|
||||
@@ -510,6 +654,12 @@ return [
|
||||
'settings_vpnapi_key_helper' => 'Your API key from vpnapi.io (required for vpnapi driver).',
|
||||
'settings_vpn_block_action' => 'VPN Block Action',
|
||||
'settings_vpn_block_action_helper' => 'Choose whether to only flag VPN traffic in statistics or actively block them with a 403 Forbidden page.',
|
||||
'settings_vpn_block_social_warning' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200"><strong>Link previews:</strong> blocking mode returns HTTP 403 for VPN/proxy/datacenter IPs. Known social crawlers (Facebook, LinkedIn, Google preview bots) are <em>still allowed</em> so OG meta and cloaked pages keep working. Human visitors on VPN may be blocked.</div>',
|
||||
'settings_vpn_detection_timeout' => 'VPN detection timeout',
|
||||
'settings_vpn_detection_timeout_helper' => 'Maximum seconds to wait for the VPN/proxy API response on each redirect.',
|
||||
'settings_vpn_detection_cache_ttl' => 'VPN detection cache TTL',
|
||||
'settings_vpn_detection_cache_ttl_helper' => 'How long (in seconds) to cache VPN/proxy lookup results per IP address.',
|
||||
'settings_trust_cdn_headers_auto_enabled' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200">The <strong>CDN Headers</strong> Geo-IP driver (Geo-IP tab) requires trusting proxy headers. This option is <strong>automatically enabled</strong> when that driver is selected and on save.</div>',
|
||||
'settings_vpn_block_flag_only' => 'Flag in stats only (allow redirect)',
|
||||
'settings_vpn_block_block_403' => 'Block traffic (serve 403 Forbidden)',
|
||||
'settings_safe_browsing_enabled' => 'Enable Google Safe Browsing URL Verification',
|
||||
@@ -568,6 +718,7 @@ return [
|
||||
'app_linking_auto_open' => 'Auto-Open',
|
||||
'app_linking_matched_description' => 'When clicked on a mobile device, this link will open immediately inside the native <strong>:app</strong> application instead of the web browser.',
|
||||
'app_linking_deep_link_scheme' => 'Mobile app path (Deep Link): :scheme',
|
||||
'app_linking_deep_link_label' => 'Deep link URL',
|
||||
'app_linking_standard_redirect' => 'This link will open in a standard mobile browser (no matching mobile app found).',
|
||||
|
||||
// App Redirect Page
|
||||
@@ -588,6 +739,11 @@ return [
|
||||
'balance_weights' => 'Adjust to 100%',
|
||||
'url_key_locked_error' => 'The URL key is locked and cannot be changed.',
|
||||
'custom_domain_locked_error' => 'The custom domain is locked and cannot be changed.',
|
||||
'custom_domain_not_owned_error' => 'You do not have permission to use this custom domain.',
|
||||
'resource_not_owned_error' => 'You do not have permission to use this resource.',
|
||||
'webhook_signing_secret_required' => 'A webhook signing secret is required when the global webhook is enabled.',
|
||||
'webhook_signing_secret' => 'Webhook Signing Secret',
|
||||
'webhook_signing_secret_helper' => 'Required when the global webhook is enabled. Outgoing requests include HMAC-SHA256 in X-ShortUrl-Signature.',
|
||||
|
||||
// Stats View & Granularity & PoP Trends
|
||||
'stats_prev_period' => 'prev period',
|
||||
@@ -725,7 +881,9 @@ return [
|
||||
// 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_live_feed_poll_interval' => 'Live via SSE',
|
||||
'stats_live_feed_mode_redis' => 'Live · Redis push',
|
||||
'stats_live_feed_mode_poll' => 'Live · SSE poll',
|
||||
'stats_col_visitor' => 'Visitor / Location',
|
||||
|
||||
// Empty state mock values
|
||||
@@ -747,4 +905,85 @@ return [
|
||||
'color_indigo' => 'Indigo',
|
||||
'color_purple' => 'Purple',
|
||||
'color_pink' => 'Pink',
|
||||
|
||||
// SEO & Social
|
||||
'tab_seo_social' => 'SEO & Social',
|
||||
'is_cloaked' => 'Link Cloaking',
|
||||
'is_cloaked_helper' => 'Mask the destination URL in the address bar using an iframe on your short link domain.',
|
||||
'is_cloaked_iframeable_hint' => 'Some destinations block iframe embedding (X-Frame-Options). Use the iframeable pre-check before enabling cloaking.',
|
||||
'do_index' => 'Search Engine Indexing',
|
||||
'do_index_helper' => 'Allow search engines (like Google) to index this short URL. Otherwise, it is served with a noindex tag.',
|
||||
'og_title' => 'Custom Title',
|
||||
'og_description' => 'Custom Description',
|
||||
'og_image' => 'Custom Image',
|
||||
'og_section_title' => 'Social Media Preview (Open Graph)',
|
||||
'og_section_desc' => 'Customize how this link looks when shared on Facebook, X, LinkedIn, WhatsApp, and Slack.',
|
||||
'seo_section_title' => 'Search Engine Indexing & Cloaking',
|
||||
'seo_section_desc' => 'Manage search engine crawling settings and stealth cloaking parameters.',
|
||||
'live_social_preview' => 'Social Card Preview',
|
||||
'live_qr_preview' => 'QR Code Preview',
|
||||
'advanced_options_section' => 'Advanced Redirect & State Options',
|
||||
|
||||
// Social Card Preview sidebar
|
||||
'og_title_placeholder' => 'Page title will appear here',
|
||||
'og_description_placeholder' => 'Page description will appear here',
|
||||
'og_empty_state_hint' => 'Paste a link to auto-generate preview',
|
||||
'password_prompt_preview_desc' => 'Visitors will see a password prompt before being redirected.',
|
||||
'fetching_metadata' => 'Fetching preview...',
|
||||
|
||||
// HTTP / API messages (visitor-facing & REST)
|
||||
'api_disabled' => 'The Developer API is currently disabled. Enable it in Short URL Settings → API & Webhooks.',
|
||||
'api_key_missing' => 'Unauthorized. API Key is missing.',
|
||||
'api_key_invalid' => 'Unauthorized. Invalid or inactive API Key.',
|
||||
'api_key_owner_required' => 'Forbidden. This API key must have an owner_user_id when link scoping is enabled.',
|
||||
'api_key_read_only' => 'Forbidden. This API key has read-only permissions.',
|
||||
'api_rate_limit_exceeded' => 'Too many requests. API key rate limit exceeded.',
|
||||
'short_url_not_found' => 'Short URL not found.',
|
||||
'redirect_vpn_blocked' => 'Access denied. VPN, Proxy, or automated scraping connection detected.',
|
||||
'redirect_rate_limited' => 'Too many requests. Please try again in :seconds seconds.',
|
||||
'redirect_destination_blocked' => 'This link destination has been blocked for security reasons.',
|
||||
'public_stats_rate_limited' => 'Too many requests.',
|
||||
'public_stats_password_invalid' => 'Password required or invalid.',
|
||||
'public_stats_password_rate_limited' => 'Too many incorrect password attempts. Please try again later.',
|
||||
'public_stats_retry_in' => 'Please try again in :seconds seconds.',
|
||||
'action_public_stats' => 'Public stats',
|
||||
'public_stats_modal_title' => 'Public statistics',
|
||||
'public_stats_modal_description' => 'Share a read-only stats page without giving access to the admin panel.',
|
||||
'public_stats_save' => 'Save',
|
||||
'public_stats_enabled_label' => 'Enable public statistics page',
|
||||
'public_stats_password_label' => 'Optional password',
|
||||
'public_stats_password_helper' => 'Leave blank to keep the current password. Clear by disabling public stats.',
|
||||
'public_stats_disabled_hint' => 'Public stats are off. Enable the toggle and save to generate a shareable link.',
|
||||
'public_stats_link_label' => 'Public stats link',
|
||||
'public_stats_save_to_activate' => 'Save to activate this link.',
|
||||
'public_stats_enabled_status' => 'Public stats are enabled.',
|
||||
'public_stats_copied' => 'Public stats link copied.',
|
||||
'public_stats_open' => 'Open',
|
||||
'public_stats_saved_enabled' => 'Public statistics enabled.',
|
||||
'public_stats_saved_disabled' => 'Public statistics disabled.',
|
||||
'public_stats_page_title' => 'Link statistics',
|
||||
'public_stats_page_subtitle' => 'Stats for /:key',
|
||||
'public_stats_date_from' => 'From',
|
||||
'public_stats_date_to' => 'To',
|
||||
'public_stats_apply_filter' => 'Apply',
|
||||
'public_stats_total_visits' => 'Total visits',
|
||||
'public_stats_unique_visits' => 'Unique visits',
|
||||
'public_stats_today' => 'Today',
|
||||
'public_stats_this_week' => 'This week',
|
||||
'public_stats_this_month' => 'This month',
|
||||
'public_stats_qr_scans' => 'QR scans',
|
||||
'public_stats_visits_by_day' => 'Visits by day',
|
||||
'public_stats_no_data' => 'No visits recorded for this period.',
|
||||
'public_stats_day' => 'Day',
|
||||
'public_stats_visits' => 'Visits',
|
||||
'public_stats_password_title' => 'Protected statistics',
|
||||
'public_stats_password_description' => 'Enter the password to view statistics for this link.',
|
||||
'public_stats_password_submit' => 'View statistics',
|
||||
'redirect_html_redirecting' => 'Redirecting you to the destination website...',
|
||||
'api_created' => 'Short URL created successfully.',
|
||||
'api_updated' => 'Short URL updated successfully.',
|
||||
'api_bulk_created' => 'Short URLs created successfully.',
|
||||
'api_deleted' => 'Short URL deleted successfully.',
|
||||
'api_bulk_deleted' => 'Short URLs deleted successfully.',
|
||||
'api_bulk_updated' => 'Short URLs updated successfully.',
|
||||
];
|
||||
|
||||
@@ -13,7 +13,7 @@ return [
|
||||
|
||||
// Form Tabs
|
||||
'tab_link' => 'Szczegóły linku',
|
||||
'tab_tracking' => 'Ustawienia śledzenia',
|
||||
'tab_tracking' => 'Śledzenie',
|
||||
'tab_qr_design' => 'Wygląd kodu QR',
|
||||
'tab_app_linking' => 'Autootwieranie aplikacji',
|
||||
|
||||
@@ -42,10 +42,68 @@ return [
|
||||
'activated_at' => 'Aktywny od',
|
||||
'max_visits' => 'Maksymalny limit wejść',
|
||||
'max_visits_helper' => 'Opcjonalny limit liczby wejść, po osiągnięciu którego link zostanie automatycznie wyłączony.',
|
||||
'max_visits_no_limit' => 'Bez limitu',
|
||||
'max_visits_suffix' => 'wejść',
|
||||
'number_stepper_decrease' => 'Zmniejsz wartość',
|
||||
'number_stepper_increase' => 'Zwiększ wartość',
|
||||
'expiration_redirect_url' => 'URL przekierowania po wygaśnięciu',
|
||||
'expiration_redirect_url_helper' => 'Adres URL, na który nastąpi przekierowanie, gdy link wygaśnie lub będzie nieaktywny (pozostaw puste, aby wyświetlić błąd 410 Gone).',
|
||||
'form_section_validity' => 'Ważność i limity',
|
||||
'use_date_validity' => 'Ustaw limity datowe',
|
||||
'use_date_validity_helper' => 'Zaplanuj, kiedy link ma się włączyć i kiedy przestać działać.',
|
||||
'validity_tab_intro' => 'Kontroluj dostępność linku w czasie oraz liczbę dozwolonych kliknięć. Wyłączone opcje oznaczają link bez ograniczeń czasowych i ilościowych.',
|
||||
'validity_schedule_card_title' => 'Harmonogram dostępności',
|
||||
'validity_schedule_card_subtitle' => 'Opcjonalna data startu i końca — idealne na kampanie, premiery i oferty limitowane.',
|
||||
'validity_schedule_empty_title' => 'Dostępny bezterminowo',
|
||||
'validity_schedule_empty_desc' => 'Włącz przełącznik powyżej, aby ustawić datę aktywacji, wygaśnięcia lub obie.',
|
||||
'validity_schedule_timeline_label' => 'Okno aktywności',
|
||||
'validity_activated_at_helper' => 'Pozostaw puste, aby link był aktywny od razu po zapisaniu.',
|
||||
'validity_expires_at_helper' => 'Opcjonalnie. Po tym momencie link przestaje przekierowywać.',
|
||||
'validity_single_use_toggle' => 'Włącz tryb jednorazowy',
|
||||
'validity_single_use_active_note' => 'Tryb jednorazowy jest włączony — link wyłączy się po pierwszej udanej wizycie. Limit wejść nie ma zastosowania.',
|
||||
'validity_max_visits_locked' => 'Niedostępne przy włączonym trybie jednorazowym',
|
||||
'tracking_visit_card_title' => 'Analityka wizyt',
|
||||
'tracking_visit_card_subtitle' => 'Zbieraj statystyki kliknięć dla tego linku — wyłącz dla linków prywatnościowych lub samych przekierowań.',
|
||||
'tracking_visit_empty_title' => 'Śledzenie wyłączone',
|
||||
'tracking_visit_empty_desc' => 'Włącz przełącznik powyżej, aby rejestrować wizyty i wybrać zapisywane dane.',
|
||||
'tracking_fields_identity_title' => 'Tożsamość i źródło',
|
||||
'tracking_fields_device_title' => 'Urządzenie i przeglądarka',
|
||||
'track_ip_desc' => 'Adres IP do geolokalizacji i wykrywania nadużyć.',
|
||||
'track_referer_desc' => 'Adres strony odsyłającej, gdy użytkownik przychodzi z innej witryny.',
|
||||
'track_browser_desc' => 'Rodzina przeglądarki, np. Chrome, Safari lub Firefox.',
|
||||
'track_browser_version_desc' => 'Dokładna wersja przeglądarki z user agenta.',
|
||||
'track_os_desc' => 'System operacyjny, np. iOS, Windows lub Android.',
|
||||
'track_os_version_desc' => 'Wersja systemu operacyjnego urządzenia.',
|
||||
'track_device_type_desc' => 'Typ urządzenia — desktop, mobile, tablet lub bot.',
|
||||
'track_browser_language_desc' => 'Preferowany język z nagłówka Accept-Language.',
|
||||
'tracking_utm_card_title' => 'Parametry kampanii',
|
||||
'tracking_utm_card_subtitle' => 'Dołącz tagi UTM do docelowego URL. Zmiany synchronizują się od razu z adresem linku.',
|
||||
'tracking_ga_card_title' => 'Google Analytics 4',
|
||||
'tracking_ga_card_subtitle' => 'Opcjonalny identyfikator pomiaru do zdarzeń przekierowań po stronie serwera (GA4 Measurement Protocol).',
|
||||
'link_destination_card_title' => 'Docelowy adres',
|
||||
'link_destination_card_subtitle' => 'Kieruj odwiedzających na jeden URL lub dziel ruch między warianty testu A/B.',
|
||||
'link_short_url_card_title' => 'Krótki link',
|
||||
'link_short_url_card_subtitle' => 'Wybierz domenę i dostosuj końcówkę adresu skróconego linku.',
|
||||
'link_behavior_card_title' => 'Zachowanie',
|
||||
'link_behavior_card_subtitle' => 'Steruj aktywnością linku i sposobem przekazywania parametrów URL.',
|
||||
'link_status_desc' => 'Po wyłączeniu odwiedzający zobaczą stronę błędu zamiast przekierowania.',
|
||||
'link_tags_card_title' => 'Tagi',
|
||||
'link_tags_card_subtitle' => 'Dodaj do pięciu etykiet, aby organizować, filtrować i raportować linki.',
|
||||
'link_notes_card_title' => 'Notatki wewnętrzne',
|
||||
'link_notes_card_subtitle' => 'Prywatna notatka w panelu — nigdy niewidoczna dla odwiedzających.',
|
||||
'link_notes_placeholder' => 'Dodaj przypomnienie dla zespołu…',
|
||||
'targeting_rules_card_title' => 'Warunkowe przekierowania',
|
||||
'targeting_rules_card_subtitle' => 'Kieruj odwiedzających na różne URL-e w zależności od urządzenia, kraju, języka lub platformy.',
|
||||
'targeting_rules_empty_title' => 'Brak reguł targetowania',
|
||||
'targeting_rules_empty_desc' => 'Dodaj regułę poniżej, aby nadpisać domyślny cel linku, gdy spełnione są określone warunki.',
|
||||
'add_targeting_rule' => 'Dodaj regułę',
|
||||
'targeting_rule_conditions_title' => 'Kiedy zastosować',
|
||||
'targeting_rule_destination_title' => 'Przekieruj na',
|
||||
'filter_duplicate_error' => 'Każdy typ filtra (Urządzenie, Platforma, Kraj, Język) można dodać tylko raz.',
|
||||
'app_linking_card_title' => 'Linkowanie aplikacji',
|
||||
'app_linking_card_subtitle' => 'Otwieraj natywną aplikację mobilną zamiast przeglądarki, gdy wykryto pasującą aplikację.',
|
||||
'app_linking_empty_title' => 'Autootwieranie aplikacji wyłączone',
|
||||
'app_linking_empty_desc' => 'Włącz przełącznik powyżej, aby podejrzeć i włączyć zachowanie deep link na urządzeniach mobilnych.',
|
||||
'notes' => 'Wewnętrzne notatki',
|
||||
'ga_tracking_id' => 'Identyfikator śledzenia Google Analytics 4',
|
||||
'ga_tracking_id_helper' => 'Opcjonalny identyfikator G-XXXXXXXXXX do śledzenia przekierowań po stronie serwera.',
|
||||
@@ -108,8 +166,10 @@ return [
|
||||
'qr_label_eye_dot_style' => 'Styl punktu oka',
|
||||
'qr_label_eye_color' => 'Kolor oka',
|
||||
'qr_label_preview' => 'Podgląd',
|
||||
'qr_option_none' => 'Brak',
|
||||
'qr_option_square' => 'Kwadrat',
|
||||
'qr_option_dots' => 'Kropki',
|
||||
'qr_option_dot' => 'Okrągły',
|
||||
'qr_option_rounded' => 'Zaokrąglony',
|
||||
'qr_option_classy' => 'Elegancki',
|
||||
'qr_option_classy_rounded' => 'Zaokrąglony elegancki',
|
||||
@@ -134,6 +194,7 @@ return [
|
||||
'qr_option_circle' => 'Koło',
|
||||
'qr_label_png' => 'PNG',
|
||||
'qr_label_svg' => 'SVG',
|
||||
'qr_save_design' => 'Zapisz projekt',
|
||||
|
||||
// Table Columns
|
||||
'col_short_url' => 'Krótki URL',
|
||||
@@ -231,7 +292,39 @@ return [
|
||||
'settings_cache_ttl' => 'TTL cache przekierowania',
|
||||
'settings_cache_ttl_helper' => 'Sekundy cachowania rekordów krótkich URL. Ustaw 0 aby wyłączyć (niezalecane na produkcji).',
|
||||
'settings_queue_connection' => 'Połączenie kolejki',
|
||||
'settings_queue_connection_helper' => 'Kolejka Laravel do asynchronicznego śledzenia wizyt. Użyj "sync" (synchroniczne), lub "redis"/"sqs" na produkcji.',
|
||||
'settings_queue_connection_helper' => 'Połączenie kolejki Laravel do asynchronicznego śledzenia wizyt. Każdy sterownik ma inne wymagania — zobacz infobox poniżej po zmianie wartości.',
|
||||
|
||||
'settings_redis_test' => 'Testuj połączenie Redis',
|
||||
'settings_redis_test_ok' => 'Połączenie Redis OK',
|
||||
'settings_redis_test_fail' => 'Połączenie Redis nieudane',
|
||||
'settings_queue_worker_test' => 'Testuj workera kolejki',
|
||||
'settings_queue_worker_test_ok' => 'Worker kolejki OK',
|
||||
'settings_queue_worker_test_fail' => 'Worker kolejki nie odpowiada',
|
||||
'settings_section_redis' => 'Połączenie Redis',
|
||||
'settings_section_redis_description' => 'Te wartości nadpisują database.redis i queue.connections.redis w runtime, gdy Queue Connection = redis. Zapisz ustawienia po zmianie.',
|
||||
'settings_redis_host' => 'Host Redis',
|
||||
'settings_redis_host_helper' => 'Host lub IP serwera Redis dla kolejek, liczników, statystyk i live feed.',
|
||||
'settings_redis_port' => 'Port Redis',
|
||||
'settings_redis_port_helper' => 'Domyślnie: 6379.',
|
||||
'settings_redis_password' => 'Hasło Redis',
|
||||
'settings_redis_password_helper' => 'Pozostaw puste, jeśli Redis nie wymaga hasła. Przechowywane bezpiecznie w ustawieniach.',
|
||||
'settings_redis_database' => 'Baza Redis (DB index)',
|
||||
'settings_redis_database_helper' => 'Indeks logicznej bazy Redis (0–15). Domyślnie: 0.',
|
||||
'settings_redis_key_prefix' => 'Prefix kluczy',
|
||||
'settings_redis_key_prefix_helper' => 'Opcjonalny prefix dla kluczy pluginu w Redis. Puste = domyślny z config.',
|
||||
|
||||
'settings_queue_worker_command' => '<strong>Wymagany proces w tle (Queue Worker):</strong> Uruchom workera na tym samym połączeniu i nazwie kolejki co powyżej (niezależnie od <code>QUEUE_CONNECTION</code> w <code>.env</code>): <code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code>',
|
||||
|
||||
'settings_queue_mode_desc_redis' => '<strong>redis</strong> — tryb produkcyjny: async joby, dedykowane liczniki/statystyki/live feed w Redis (phpredis lub Predis), auto buforowanie. Skonfiguruj Redis poniżej (nadpisuje .env w runtime). Użyj przycisków testu przed zapisem.',
|
||||
'settings_queue_mode_desc_database' => '<strong>database</strong> — joby w tabeli <code>jobs</code>. Bez dedykowanego Redis — chyba że włączysz buforowanie ręcznie.',
|
||||
'settings_queue_mode_desc_sqs' => '<strong>sqs</strong> — joby przez AWS SQS. Wymaga poprawnych credentiali AWS w <code>config/queue.php</code>.',
|
||||
'settings_queue_mode_desc_beanstalkd' => '<strong>beanstalkd</strong> — joby przez Beanstalkd. Wymaga działającego serwera beanstalkd.',
|
||||
'settings_queue_mode_desc_deferred' => '<strong>deferred</strong> — joby po odpowiedzi HTTP w tym samym procesie PHP. Bez workera, ale większe obciążenie po response przy ruchu.',
|
||||
'settings_queue_mode_desc_background' => '<strong>background</strong> — sterownik background Laravel: joby po response bez workera (podobnie jak deferred).',
|
||||
'settings_queue_mode_desc_failover' => '<strong>failover</strong> — łańcuch failover z <code>config/queue.php</code>. Upewnij się, że co najmniej jeden backend działa.',
|
||||
'settings_queue_mode_desc_default' => '<strong>:connection</strong> — async tracking przez to połączenie Laravel. Skonfiguruj je w <code>config/queue.php</code>.',
|
||||
|
||||
'settings_queue_mode_info_sync' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="text-sm text-neutral-800 dark:text-neutral-300"><strong>sync</strong> — wizyty zapisywane podczas przekierowania (bez workera). Dev i niski ruch. Opcjonalne ręczne buforowanie liczników poniżej.</div></div>',
|
||||
|
||||
'settings_geoip_enabled' => 'Włącz wykrywanie Geo-IP',
|
||||
'settings_geoip_enabled_helper' => 'Wykrywaj i zapisuj kraj odwiedzającego przy każdej wizycie.',
|
||||
@@ -260,11 +353,15 @@ return [
|
||||
'settings_ga4_verify_ok' => '✅ Klucz API jest prawidłowy — połączenie z GA4 udane',
|
||||
'settings_ga4_verify_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
|
||||
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
|
||||
'settings_ga4_verify_measurement_id' => 'Measurement ID do testu połączenia',
|
||||
'settings_ga4_verify_measurement_id_helper' => 'Podaj dokładny G-XXXXXXXXXX ze strumienia GA4 powiązanego z kluczem API. Wymagane, chyba że używasz Firebase App ID.',
|
||||
'settings_ga4_verify_measurement_required' => 'Podaj Measurement ID (G-XXXXXXXXXX) lub Firebase App ID, aby przetestować połączenie.',
|
||||
'settings_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
|
||||
|
||||
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
||||
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
|
||||
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
|
||||
'settings_buffering_redis_auto' => 'Włączane automatycznie przy kolejce Redis. Liczniki linków, statystyki „dziś” i live feed korzystaj z dedykowanego połączenia Redis kolejki (phpredis lub Predis) — niezależnie od CACHE_STORE. Zaplanuj short-url:sync-counters co minutę.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
@@ -272,10 +369,12 @@ return [
|
||||
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
|
||||
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Uwaga (Bezpieczeństwo adresów IP):</strong> Włączyłeś ufanie nagłówkom proxy. Włączaj tę opcję <u>wyłącznie</u> wtedy, gdy Twoja strona jest podpięta pod:<br>• <strong>Cloudflare</strong> (odczyt z nagłówka CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> lub inny system CDN<br>• <strong>Nginx/Apache reverse proxy</strong> przekazujący nagłówki X-Forwarded-For.<br><br><strong>Wyłącz tę opcję</strong>, jeśli Twój serwer łączy się z użytkownikami bezpośrednio. Jeśli pozostawisz ją włączoną bez CDN, złośliwi użytkownicy mogą łatwo sfałszować swój adres IP, wysyłając własny nagłówek.</span></div></div>',
|
||||
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Brak zaufania do nagłówków proxy:</strong> Wybrałeś <em>Nagłówki CDN</em> jako sterownik wykrywania lokalizacji, ale opcja <strong>„Ufaj nagłówkom CDN i proxy”</strong> w zakładce <em>Ogólne</em> jest wyłączona. Wykrywanie kraju nie będzie działać, dopóki jej nie włączysz.</span></div></div>',
|
||||
'settings_geoip_headers_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-950/20" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-blue-800 dark:text-blue-300" aria-label="Info"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-blue-900 dark:text-blue-200" data-component-part="callout-content"><span><strong>Sterownik Nagłówki CDN:</strong> Zapis ustawień z tym sterownikiem automatycznie włącza opcję <strong>Ufaj nagłówkom CDN i proxy</strong>. Gdy brakuje nagłówków kraju z CDN, wtyczka korzysta z MaxMind, a następnie ip-api.com (z limitem zapytań) zamiast zwracać puste dane geo.</span></div></div>',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Odwiedzono od',
|
||||
'stats_filter_visited_until' => 'Odwiedzono do',
|
||||
'stats_filter_counted_in_stats' => 'Tylko wizyty w statystykach',
|
||||
'stats_action_export' => 'Eksportuj CSV',
|
||||
'stats_csv_time' => 'Czas',
|
||||
'stats_csv_ip' => 'Adres IP',
|
||||
@@ -302,8 +401,11 @@ return [
|
||||
'form_section_options' => 'Opcje',
|
||||
'form_section_notes' => 'Wewnętrzne notatki',
|
||||
'form_section_tracking' => 'Śledzenie wizyt',
|
||||
'form_section_tracking_desc' => 'Włącz lub wyłącz śledzenie wizyt i statystyki dla tego skróconego linku.',
|
||||
'form_section_tracked_fields' => 'Śledzone pola',
|
||||
'form_section_tracked_fields_desc' => 'Wybierz, które metryki są zbierane dla tego linku — przydatne pod kątem analityki i prywatności.',
|
||||
'form_section_analytics' => 'Zewnętrzne systemy analityczne',
|
||||
'form_section_analytics_desc' => 'Zintegruj ten link z Google Analytics, podając identyfikator strumienia danych.',
|
||||
|
||||
// New Dashboard Analytics Keys
|
||||
'stats_card_top_source' => 'Główne źródło UTM',
|
||||
@@ -324,15 +426,35 @@ return [
|
||||
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
|
||||
|
||||
// New Targeting & Security Tab and fields
|
||||
'tab_targeting' => 'Targetowanie i bezpieczeństwo',
|
||||
'tab_targeting' => 'Targetowanie',
|
||||
'tab_password' => 'Hasło',
|
||||
'tab_validity' => 'Wygasanie',
|
||||
'expiration_dates_section_title' => 'Wygasanie czasowe',
|
||||
'visit_limits_section_title' => 'Limity wizyt',
|
||||
'security_section_title' => 'Kontrola bezpieczeństwa',
|
||||
'password' => 'Hasło dostępu',
|
||||
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
|
||||
'password_card_title' => 'Ochrona hasłem',
|
||||
'password_card_subtitle' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
|
||||
'warning_page_card_title' => 'Strona ostrzegająca przed przekierowaniem',
|
||||
'warning_page_card_subtitle' => 'Pokaż ekran pośredni przed przekierowaniem odwiedzających na docelowy adres.',
|
||||
'warning_page_empty_title' => 'Strona ostrzegająca wyłączona',
|
||||
'warning_page_empty_desc' => 'Włącz przełącznik powyżej, aby pokazać ekran bezpieczeństwa przed zewnętrznym przekierowaniem.',
|
||||
'warning_page_active_title' => 'Strona ostrzegająca włączona',
|
||||
'confirm_password' => 'Potwierdź hasło',
|
||||
'new_password' => 'Nowe hasło',
|
||||
'change_password' => 'Zmień hasło',
|
||||
'remove_password' => 'Usuń hasło',
|
||||
'password_status_active' => 'Ochrona hasłem jest włączona.',
|
||||
'password_status_active_desc' => 'Link jest zabezpieczony. Dostęp wymaga podania prawidłowego hasła.',
|
||||
'password_empty_state_title' => 'Brak zabezpieczeń',
|
||||
'password_empty_state_desc' => 'Dodaj hasło, aby ograniczyć dostęp do tego skróconego linku tylko dla wybranych osób.',
|
||||
'password_settings_section' => 'Ustawienia hasła',
|
||||
'password_change_short' => 'Zmień',
|
||||
'password_remove_short' => 'Usuń',
|
||||
'save_password' => 'Zapisz hasło',
|
||||
'expiration_dates_section_desc' => 'Zarządzaj dostępnością linku w czasie. Możesz zaplanować start kampanii lub jej automatyczne zakończenie.',
|
||||
'visit_limits_section_desc' => 'Ogranicz maksymalną liczbę kliknięć. Idealne do biletów jednorazowych lub ofert limitowanych.',
|
||||
'set_password' => 'Ustaw hasło',
|
||||
'cancel' => 'Anuluj',
|
||||
'confirm' => 'Potwierdź',
|
||||
@@ -389,8 +511,8 @@ return [
|
||||
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
|
||||
'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.',
|
||||
'settings_aggregation_enabled' => 'Włącz automatyczne usuwanie surowych logów',
|
||||
'settings_aggregation_enabled_helper' => 'Codzienne zadanie o 02:00 zawsze agreguje wizyty do statystyk dziennych. Ten przełącznik kontroluje wyłącznie usuwanie surowych logów starszych niż wybrany okres retencji.',
|
||||
'retention_30_days' => '30 dni',
|
||||
'retention_60_days' => '60 dni',
|
||||
'retention_90_days' => '90 dni',
|
||||
@@ -400,6 +522,7 @@ return [
|
||||
'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.',
|
||||
'settings_rate_limiting_route_info' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200"><strong>Dwa poziomy:</strong> trasy przekierowań mają też domyślny limit middleware pakietu (<code>120/min</code> na IP). Po włączeniu limitera poniżej <em>oba</em> mogą zwrócić HTTP 429 — pierwszy osiągnięty limit wygrywa.</div>',
|
||||
'settings_rate_limiting_max_attempts' => 'Maksymalna dopuszczalna liczba przekierowań',
|
||||
'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
|
||||
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
|
||||
@@ -408,7 +531,7 @@ return [
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Nazwa kolejki',
|
||||
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Uruchom workera na tym samym połączeniu i nazwie kolejki co powyżej (niezależnie od <code>QUEUE_CONNECTION</code> w <code>.env</code>):<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej statystyk',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Czas (w sekundach) buforowania obliczeń statystyk linków na pulpicie. Kiedy przeglądasz statystyki, system pobiera dane z cache zamiast każdorazowo przeliczać miliony rekordów w bazie danych. Domyślnie: 300 (5 minut). Maksymalnie: 86400 (24 godziny).',
|
||||
|
||||
@@ -460,14 +583,22 @@ return [
|
||||
'tab_marketing' => 'Marketing i API',
|
||||
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
|
||||
'marketing_pixels_desc' => 'Dodaj skrypty śledzące, aby dołączać ciasteczka odwiedzających i budować grupy remarketingowe reklam.',
|
||||
'marketing_pixels_empty_title' => 'Brak przypisanych pikseli',
|
||||
'marketing_pixels_empty_desc' => 'Wybierz piksele retargetingowe, które mają uruchamiać skrypty śledzące po kliknięciu w ten link.',
|
||||
'marketing_pixels_select_placeholder' => 'Wybierz piksele retargetingowe…',
|
||||
'marketing_webhook_empty_title' => 'Brak skonfigurowanego webhooka',
|
||||
'marketing_webhook_empty_desc' => 'Dodaj docelowy adres URL, aby otrzymywać powiadomienia HTTP POST w czasie rzeczywistym po każdym kliknięciu.',
|
||||
'pixel_meta' => 'Meta Pixel ID',
|
||||
'pixel_google' => 'Google Tag / GA4 ID',
|
||||
'pixel_linkedin' => 'LinkedIn Partner ID',
|
||||
'marketing_webhooks_title' => 'Integracja Webhook linku',
|
||||
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
|
||||
'webhook_url' => 'Dedykowany Webhook URL',
|
||||
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Przekazuje ono szczegółowe metadane wizyty w formacie JSON (klucz linku, adres IP, kraj, przeglądarka, system operacyjny, referer i parametry UTM).',
|
||||
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Payload zawiera szczegółowe metadane wizyty w JSON (klucz linku, docelowy URL, urządzenie, przeglądarka, system operacyjny, geolokalizacja, referer, parametry UTM i flagę skanowania QR). Adresy IP nie są uwzględniane w payloadzie webhooka.',
|
||||
'webhook_show_payload' => 'Pokaż przykładowy payload JSON',
|
||||
'webhook_payload_modal_title' => 'Przykładowy payload webhooka',
|
||||
'webhook_payload_modal_desc' => 'Przykładowe body JSON dla zdarzenia visited. Rzeczywiste wartości zależą od każdego kliknięcia.',
|
||||
'webhook_payload_copy' => 'Kopiuj payload do schowka',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API i Webhooki',
|
||||
@@ -497,6 +628,19 @@ return [
|
||||
// Ochrona v2.0
|
||||
'settings_section_security_v2' => 'Bezpieczeństwo i ochrona przed nadużyciami v2.0',
|
||||
'settings_section_security_v2_desc' => 'Skonfiguruj wykrywanie sieci VPN/Proxy oraz walidację adresów przez Google Safe Browsing.',
|
||||
'settings_section_analytics_security' => 'Analityka i wykrywanie botów',
|
||||
'settings_section_analytics_security_desc' => 'Opcjonalna deduplikacja kliknięć i zaawansowane ustawienia botów.',
|
||||
'settings_click_dedup_enabled' => 'Włącz deduplikację kliknięć',
|
||||
'settings_click_dedup_enabled_helper' => 'Ignoruj powtarzające się kliknięcia z tego samego IP w określonym oknie czasu.',
|
||||
'settings_click_dedup_hours' => 'Okno deduplikacji (godziny)',
|
||||
'settings_click_dedup_hours_helper' => 'Jak długo traktować powtarzające się kliknięcia z tego samego IP jako duplikaty.',
|
||||
'settings_bot_verify_googlebot' => 'Weryfikuj IP Googlebota',
|
||||
'settings_bot_verify_googlebot_helper' => 'Reverse-DNS + forward IP dla user agentów Googlebota.',
|
||||
'settings_bot_debug_secret' => 'Sekret debugowania botów',
|
||||
'settings_bot_debug_secret_helper' => 'Wymagana wartość dla ?bot=1 poza local/testing.',
|
||||
'outbound_url_blocked' => 'Ten adres URL wychodzący jest niedozwolony.',
|
||||
'api_key_owner_user_id' => 'ID właściciela',
|
||||
'api_key_owner_user_id_helper' => 'Wymagane, gdy włączone jest scope linków do użytkownika (domyślnie). Ogranicza klucz do linków tego użytkownika.',
|
||||
'settings_vpn_detection_enabled' => 'Włącz wykrywanie VPN i Proxy',
|
||||
'settings_vpn_detection_enabled_helper' => 'Po włączeniu każda wizyta będzie sprawdzana pod kątem korzystania z połączeń VPN, proxy lub sieci Tor.',
|
||||
'settings_vpn_driver' => 'Sterownik wykrywania',
|
||||
@@ -507,6 +651,12 @@ return [
|
||||
'settings_vpnapi_key_helper' => 'Twój klucz API pobrany z vpnapi.io (wymagany dla sterownika vpnapi).',
|
||||
'settings_vpn_block_action' => 'Akcja blokowania VPN',
|
||||
'settings_vpn_block_action_helper' => 'Wybierz, czy ruch z sieci VPN ma być jedynie flagowany w statystykach, czy aktywnie blokowany stroną 403 Forbidden.',
|
||||
'settings_vpn_block_social_warning' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200"><strong>Podglądy linków:</strong> tryb blokowania zwraca HTTP 403 dla IP VPN/proxy/hostingu. Znane boty social (Facebook, LinkedIn, Google preview) są <em>nadal wpuszczane</em>, aby OG i cloaking działały. Ludzie na VPN mogą dostać 403.</div>',
|
||||
'settings_vpn_detection_timeout' => 'Timeout detekcji VPN',
|
||||
'settings_vpn_detection_timeout_helper' => 'Maksymalny czas oczekiwania (w sekundach) na odpowiedź API VPN/proxy przy przekierowaniu.',
|
||||
'settings_vpn_detection_cache_ttl' => 'TTL cache detekcji VPN',
|
||||
'settings_vpn_detection_cache_ttl_helper' => 'Jak długo (w sekundach) cache’ować wynik lookupu VPN/proxy dla danego IP.',
|
||||
'settings_trust_cdn_headers_auto_enabled' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200">Sterownik <strong>Nagłówki CDN</strong> (zakładka Geo-IP) wymaga ufania nagłówkom proxy. Opcja jest <strong>automatycznie włączana</strong> przy tym sterowniku i przy zapisie ustawień.</div>',
|
||||
'settings_vpn_block_flag_only' => 'Tylko flaguj w statystykach (zezwól na przekierowanie)',
|
||||
'settings_vpn_block_block_403' => 'Blokuj ruch (wyświetl błąd 403 Forbidden)',
|
||||
'settings_safe_browsing_enabled' => 'Włącz weryfikację przez Google Safe Browsing',
|
||||
@@ -565,6 +715,7 @@ return [
|
||||
'app_linking_auto_open' => 'Autootwieranie',
|
||||
'app_linking_matched_description' => 'Po kliknięciu na telefonie lub tablecie, ten link otworzy się od razu w dedykowanej aplikacji <strong>:app</strong> zamiast w zwykłej przeglądarce.',
|
||||
'app_linking_deep_link_scheme' => 'Używany schemat (Deep Link): :scheme',
|
||||
'app_linking_deep_link_label' => 'Adres deep link',
|
||||
'app_linking_standard_redirect' => 'Ten link otworzy się w zwykłej przeglądarce internetowej (brak dopasowanej aplikacji mobilnej).',
|
||||
|
||||
// App Redirect Page
|
||||
@@ -585,6 +736,11 @@ return [
|
||||
'balance_weights' => 'Wyreguluj do 100%',
|
||||
'url_key_locked_error' => 'Klucz URL jest zablokowany i nie może być zmieniony.',
|
||||
'custom_domain_locked_error' => 'Domena niestandardowa jest zablokowana i nie może być zmieniona.',
|
||||
'custom_domain_not_owned_error' => 'Nie masz uprawnień do użycia tej domeny niestandardowej.',
|
||||
'resource_not_owned_error' => 'Nie masz uprawnień do użycia tego zasobu.',
|
||||
'webhook_signing_secret_required' => 'Włączenie globalnego webhooka wymaga ustawienia sekretu podpisu HMAC.',
|
||||
'webhook_signing_secret' => 'Sekret podpisu webhooka',
|
||||
'webhook_signing_secret_helper' => 'Wymagany, gdy włączony jest globalny webhook. Żądania wychodzące zawierają HMAC-SHA256 w nagłówku X-ShortUrl-Signature.',
|
||||
|
||||
// Stats View & Granularity & PoP Trends
|
||||
'stats_prev_period' => 'poprzedni okres',
|
||||
@@ -723,7 +879,9 @@ return [
|
||||
// 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_live_feed_poll_interval' => 'Na żywo (SSE)',
|
||||
'stats_live_feed_mode_redis' => 'Na żywo · Redis push',
|
||||
'stats_live_feed_mode_poll' => 'Na żywo · SSE poll',
|
||||
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
|
||||
|
||||
// Puste stany - makiety
|
||||
@@ -745,4 +903,79 @@ return [
|
||||
'color_indigo' => 'Indygo',
|
||||
'color_purple' => 'Fioletowy',
|
||||
'color_pink' => 'Różowy',
|
||||
|
||||
// SEO & Social
|
||||
'tab_seo_social' => 'SEO i Social',
|
||||
'is_cloaked' => 'Maskowanie linku (Cloaking)',
|
||||
'is_cloaked_helper' => 'Maskuj adres docelowy w pasku adresu przeglądarki za pomocą ramki iframe na domenie krótkiego linku.',
|
||||
'is_cloaked_iframeable_hint' => 'Niektóre strony docelowe blokują osadzanie w iframe (X-Frame-Options). Przed włączeniem cloakingu użyj testu iframeable.',
|
||||
'do_index' => 'Indeksowanie w wyszukiwarkach',
|
||||
'do_index_helper' => 'Zezwól wyszukiwarkom (np. Google) na indeksowanie tego krótkiego linku. W przeciwnym razie będzie on serwowany z tagiem noindex.',
|
||||
'og_title' => 'Niestandardowy tytuł',
|
||||
'og_description' => 'Niestandardowy opis',
|
||||
'og_image' => 'Niestandardowy obrazek',
|
||||
'og_section_title' => 'Podgląd w mediach społecznościowych (Open Graph)',
|
||||
'og_section_desc' => 'Dostosuj wygląd tego linku po udostępnieniu na Facebooku, X (Twitterze), LinkedIn, WhatsApp czy Slacku.',
|
||||
'seo_section_title' => 'Indeksowanie i maskowanie linków',
|
||||
'seo_section_desc' => 'Zarządzaj widocznością w wyszukiwarkach oraz parametrami maskowania linków docelowych.',
|
||||
'live_social_preview' => 'Podgląd karty społecznościowej',
|
||||
'live_qr_preview' => 'Podgląd kodu QR',
|
||||
'advanced_options_section' => 'Zaawansowane opcje przekierowań i stanu',
|
||||
'fetching_metadata' => 'Pobieranie podglądu...',
|
||||
|
||||
// HTTP / API messages (visitor-facing & REST)
|
||||
'api_disabled' => 'API deweloperskie jest wyłączone. Włącz je w Ustawieniach skrótów → API i webhooki.',
|
||||
'api_key_missing' => 'Brak autoryzacji. Nie podano klucza API.',
|
||||
'api_key_invalid' => 'Brak autoryzacji. Nieprawidłowy lub nieaktywny klucz API.',
|
||||
'api_key_owner_required' => 'Brak dostępu. Ten klucz API musi mieć owner_user_id, gdy włączone jest scope linków do użytkownika.',
|
||||
'api_key_read_only' => 'Brak dostępu. Ten klucz API ma uprawnienia tylko do odczytu.',
|
||||
'api_rate_limit_exceeded' => 'Zbyt wiele żądań. Przekroczono limit klucza API.',
|
||||
'short_url_not_found' => 'Nie znaleziono skrótu URL.',
|
||||
'redirect_vpn_blocked' => 'Odmowa dostępu. Wykryto VPN, proxy lub automatyczne pobieranie.',
|
||||
'redirect_rate_limited' => 'Zbyt wiele żądań. Spróbuj ponownie za :seconds sek.',
|
||||
'redirect_destination_blocked' => 'Docelowy adres tego linku został zablokowany ze względów bezpieczeństwa.',
|
||||
'public_stats_rate_limited' => 'Zbyt wiele żądań.',
|
||||
'public_stats_password_invalid' => 'Wymagane lub nieprawidłowe hasło.',
|
||||
'public_stats_password_rate_limited' => 'Zbyt wiele nieudanych prób hasła. Spróbuj ponownie później.',
|
||||
'public_stats_retry_in' => 'Spróbuj ponownie za :seconds s.',
|
||||
'action_public_stats' => 'Statystyki publiczne',
|
||||
'public_stats_modal_title' => 'Publiczne statystyki',
|
||||
'public_stats_modal_description' => 'Udostępnij stronę ze statystykami bez dostępu do panelu administracyjnego.',
|
||||
'public_stats_save' => 'Zapisz',
|
||||
'public_stats_enabled_label' => 'Włącz publiczną stronę statystyk',
|
||||
'public_stats_password_label' => 'Opcjonalne hasło',
|
||||
'public_stats_password_helper' => 'Pozostaw puste, aby zachować obecne hasło. Wyłącz statystyki, aby je usunąć.',
|
||||
'public_stats_disabled_hint' => 'Statystyki publiczne są wyłączone. Włącz przełącznik i zapisz, aby wygenerować link.',
|
||||
'public_stats_link_label' => 'Link do statystyk',
|
||||
'public_stats_save_to_activate' => 'Zapisz, aby aktywować ten link.',
|
||||
'public_stats_enabled_status' => 'Statystyki publiczne są włączone.',
|
||||
'public_stats_copied' => 'Skopiowano link do statystyk.',
|
||||
'public_stats_open' => 'Otwórz',
|
||||
'public_stats_saved_enabled' => 'Włączono publiczne statystyki.',
|
||||
'public_stats_saved_disabled' => 'Wyłączono publiczne statystyki.',
|
||||
'public_stats_page_title' => 'Statystyki linku',
|
||||
'public_stats_page_subtitle' => 'Statystyki dla /:key',
|
||||
'public_stats_date_from' => 'Od',
|
||||
'public_stats_date_to' => 'Do',
|
||||
'public_stats_apply_filter' => 'Filtruj',
|
||||
'public_stats_total_visits' => 'Wszystkie wizyty',
|
||||
'public_stats_unique_visits' => 'Unikalne wizyty',
|
||||
'public_stats_today' => 'Dziś',
|
||||
'public_stats_this_week' => 'Ten tydzień',
|
||||
'public_stats_this_month' => 'Ten miesiąc',
|
||||
'public_stats_qr_scans' => 'Skany QR',
|
||||
'public_stats_visits_by_day' => 'Wizyty wg dnia',
|
||||
'public_stats_no_data' => 'Brak wizyt w tym okresie.',
|
||||
'public_stats_day' => 'Dzień',
|
||||
'public_stats_visits' => 'Wizyty',
|
||||
'public_stats_password_title' => 'Chronione statystyki',
|
||||
'public_stats_password_description' => 'Podaj hasło, aby zobaczyć statystyki tego linku.',
|
||||
'public_stats_password_submit' => 'Pokaż statystyki',
|
||||
'redirect_html_redirecting' => 'Trwa przekierowanie na stronę docelową...',
|
||||
'api_created' => 'Skrót URL został utworzony.',
|
||||
'api_updated' => 'Skrót URL został zaktualizowany.',
|
||||
'api_bulk_created' => 'Skróty URL zostały utworzone.',
|
||||
'api_deleted' => 'Skrót URL został usunięty.',
|
||||
'api_bulk_deleted' => 'Skróty URL zostały usunięte.',
|
||||
'api_bulk_updated' => 'Skróty URL zostały zaktualizowane.',
|
||||
];
|
||||
|
||||
7
resources/svg/qr-dots-classy-rounded.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 5.5A2.5 2.5 0 0 1 5.5 3h1A1.5 1.5 0 0 1 8 4.5v1A2.5 2.5 0 0 1 5.5 8h-1A1.5 1.5 0 0 1 3 6.5v-1z"/>
|
||||
<path d="M16 5.5A2.5 2.5 0 0 1 18.5 3h1A1.5 1.5 0 0 1 21 4.5v1A2.5 2.5 0 0 1 18.5 8h-1A1.5 1.5 0 0 1 16 6.5v-1z"/>
|
||||
<path d="M9.5 12A2.5 2.5 0 0 1 12 9.5h1A1.5 1.5 0 0 1 14.5 11v1A2.5 2.5 0 0 1 12 14.5h-1A1.5 1.5 0 0 1 9.5 13v-1z"/>
|
||||
<path d="M3 18.5A2.5 2.5 0 0 1 5.5 16h1A1.5 1.5 0 0 1 8 17.5v1A2.5 2.5 0 0 1 5.5 21h-1A1.5 1.5 0 0 1 3 19.5v-1z"/>
|
||||
<path d="M16 18.5A2.5 2.5 0 0 1 18.5 16h1A1.5 1.5 0 0 1 21 17.5v1A2.5 2.5 0 0 1 18.5 21h-1A1.5 1.5 0 0 1 16 19.5v-1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 685 B |
7
resources/svg/qr-dots-classy.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 5.5C3 4.1 4.1 3 5.5 3H8v2.5C8 6.9 6.9 8 5.5 8H3V5.5z"/>
|
||||
<path d="M16 5.5C16 4.1 17.1 3 18.5 3H21v2.5C21 6.9 19.9 8 18.5 8H16V5.5z"/>
|
||||
<path d="M9.5 12C9.5 10.6 10.6 9.5 12 9.5H14.5V12C14.5 13.4 13.4 14.5 12 14.5H9.5V12z"/>
|
||||
<path d="M3 18.5C3 17.1 4.1 16 5.5 16H8v2.5C8 19.9 6.9 21 5.5 21H3V18.5z"/>
|
||||
<path d="M16 18.5C16 17.1 17.1 16 18.5 16H21v2.5C21 19.9 19.9 21 18.5 21H16V18.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 503 B |
7
resources/svg/qr-dots-dots.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="5.5" cy="5.5" r="2.5" />
|
||||
<circle cx="18.5" cy="5.5" r="2.5" />
|
||||
<circle cx="12" cy="12" r="2.5" />
|
||||
<circle cx="5.5" cy="18.5" r="2.5" />
|
||||
<circle cx="18.5" cy="18.5" r="2.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 295 B |
7
resources/svg/qr-dots-extra-rounded.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="3" y="3" width="5" height="5" rx="2.2" />
|
||||
<rect x="16" y="3" width="5" height="5" rx="2.2" />
|
||||
<rect x="9.5" y="9.5" width="5" height="5" rx="2.2" />
|
||||
<rect x="3" y="16" width="5" height="5" rx="2.2" />
|
||||
<rect x="16" y="16" width="5" height="5" rx="2.2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
7
resources/svg/qr-dots-rounded.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="3" y="3" width="5" height="5" rx="1.5" />
|
||||
<rect x="16" y="3" width="5" height="5" rx="1.5" />
|
||||
<rect x="9.5" y="9.5" width="5" height="5" rx="1.5" />
|
||||
<rect x="3" y="16" width="5" height="5" rx="1.5" />
|
||||
<rect x="16" y="16" width="5" height="5" rx="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
7
resources/svg/qr-dots-square.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="3" y="3" width="5" height="5" />
|
||||
<rect x="16" y="3" width="5" height="5" />
|
||||
<rect x="9.5" y="9.5" width="5" height="5" />
|
||||
<rect x="3" y="16" width="5" height="5" />
|
||||
<rect x="16" y="16" width="5" height="5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
6
resources/svg/qr-eye-dot-dot.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<!-- Light outer border -->
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" />
|
||||
<!-- Solid inner circle -->
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
6
resources/svg/qr-eye-dot-none.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<!-- Light outer border -->
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" />
|
||||
<!-- No inner center (slashed or empty) -->
|
||||
<line x1="8" y1="16" x2="16" y2="8" opacity="0.6" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 367 B |
6
resources/svg/qr-eye-dot-square.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<!-- Light outer border -->
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" />
|
||||
<!-- Solid inner square -->
|
||||
<rect x="8.5" y="8.5" width="7" height="7" rx="0.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
3
resources/svg/qr-eye-square-dot.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 158 B |
3
resources/svg/qr-eye-square-extra-rounded.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 176 B |
5
resources/svg/qr-eye-square-none.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<!-- Slashed border -->
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke-dasharray="2 2" opacity="0.4" />
|
||||
<line x1="3" y1="21" x2="21" y2="3" opacity="0.6" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
3
resources/svg/qr-eye-square-square.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="0.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 178 B |
@@ -1,105 +1,111 @@
|
||||
@php
|
||||
$matchedAppId = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::matchApp($destinationUrl);
|
||||
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
|
||||
$appCount = count($apps);
|
||||
@endphp
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- macOS style main block -->
|
||||
<div class="p-4 rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/30 shadow-none">
|
||||
|
||||
<!-- Header Info -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-3 mb-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-850 dark:text-white uppercase tracking-wider">
|
||||
{{ __('filament-short-url::default.app_linking_supported_apps') }}
|
||||
</h4>
|
||||
<div class="app-linking-preview">
|
||||
@if ($matchedAppId && isset($apps[$matchedAppId]))
|
||||
@php
|
||||
$matchedApp = $apps[$matchedAppId];
|
||||
$matchedDomain = explode('/', $matchedApp['domains'][0])[0];
|
||||
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
|
||||
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
|
||||
@endphp
|
||||
|
||||
<div class="app-linking-status app-linking-status--matched">
|
||||
<div class="app-linking-status-main">
|
||||
<div class="app-linking-status-icon app-linking-status-icon--matched">
|
||||
<img
|
||||
src="{{ $matchedFavicon }}"
|
||||
alt="{{ $matchedApp['name'] }}"
|
||||
loading="lazy"
|
||||
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="app-linking-status-copy">
|
||||
<div class="app-linking-status-title-row">
|
||||
<p class="app-linking-status-title">{{ $matchedApp['name'] }}</p>
|
||||
<span class="app-linking-status-badge app-linking-status-badge--success">
|
||||
{{ __('filament-short-url::default.app_linking_auto_open') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="app-linking-status-desc">
|
||||
{!! __('filament-short-url::default.app_linking_matched_description', ['app' => e($matchedApp['name'])]) !!}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[10px]">
|
||||
<span class="text-gray-500 dark:text-gray-450 font-medium">
|
||||
{{ __('filament-short-url::default.app_linking_supported_os') }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-gray-650 dark:text-gray-300 font-semibold">iOS</span>
|
||||
<span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-gray-650 dark:text-gray-300 font-semibold">Android</span>
|
||||
|
||||
<div class="app-linking-deep-link">
|
||||
<span class="app-linking-deep-link-label">{{ __('filament-short-url::default.app_linking_deep_link_label') }}</span>
|
||||
<code class="app-linking-deep-link-value">{{ $deepLink }}</code>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="app-linking-status app-linking-status--browser">
|
||||
<div class="app-linking-status-icon app-linking-status-icon--browser">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.467.732-3.553" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="app-linking-status-copy">
|
||||
<p class="app-linking-status-title">{{ __('filament-short-url::default.app_linking_standard_redirect') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="app-linking-catalog">
|
||||
<div class="app-linking-catalog-header">
|
||||
<div class="app-linking-catalog-heading">
|
||||
<p class="app-linking-catalog-title">{{ __('filament-short-url::default.app_linking_supported_apps') }}</p>
|
||||
<p class="app-linking-catalog-subtitle">
|
||||
{{ __('filament-short-url::default.app_linking_preconfigured_count', ['count' => $appCount]) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-linking-os-badges" aria-label="{{ __('filament-short-url::default.app_linking_supported_os') }}">
|
||||
<span class="app-linking-os-badge">iOS</span>
|
||||
<span class="app-linking-os-badge">Android</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matched App Banner -->
|
||||
@if ($matchedAppId && isset($apps[$matchedAppId]))
|
||||
@php
|
||||
$matchedApp = $apps[$matchedAppId];
|
||||
$matchedDomain = explode('/', $matchedApp['domains'][0])[0];
|
||||
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
|
||||
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
|
||||
@endphp
|
||||
<div class="mb-4 p-4 rounded-xl bg-emerald-500/5 dark:bg-emerald-500/5 border border-emerald-500 dark:border-emerald-600 text-xs shadow-none">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Squircle Favicon Box -->
|
||||
<div class="w-10 h-10 rounded-lg bg-white dark:bg-gray-800 flex items-center justify-center p-2 flex-shrink-0">
|
||||
<img src="{{ $matchedFavicon }}" alt="{{ $matchedApp['name'] }}" class="w-6 h-6 object-contain" onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'">
|
||||
</div>
|
||||
|
||||
<div class="flex-grow min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h5 class="text-xs font-bold text-emerald-850 dark:text-emerald-450">
|
||||
{{ __('filament-short-url::default.app_linking_redirect_active', ['app' => $matchedApp['name']]) }}
|
||||
</h5>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500 text-white uppercase tracking-wider">
|
||||
{{ __('filament-short-url::default.app_linking_auto_open') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-emerald-700 dark:text-emerald-450 mt-1 leading-relaxed">
|
||||
{!! __('filament-short-url::default.app_linking_matched_description', ['app' => e($matchedApp['name'])]) !!}
|
||||
</p>
|
||||
<div class="mt-2 text-[10px] font-mono text-emerald-600 dark:text-emerald-400 break-all select-all">
|
||||
{{ __('filament-short-url::default.app_linking_deep_link_scheme', ['scheme' => $deepLink]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Standard Web Redirect banner -->
|
||||
<div class="mb-4 p-3.5 rounded-xl bg-gray-100/50 dark:bg-gray-800/10 border border-gray-200 dark:border-gray-800 text-xs flex items-center gap-3 shadow-none">
|
||||
<div class="w-7 h-7 rounded-[6px] bg-white dark:bg-gray-800 flex items-center justify-center p-1.5 flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-gray-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-gray-500 dark:text-gray-450 leading-relaxed text-[11px]">
|
||||
{{ __('filament-short-url::default.app_linking_standard_redirect') }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Flat iOS-like Grid without Borders -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
|
||||
<div class="app-linking-grid">
|
||||
@foreach ($apps as $appId => $app)
|
||||
@php
|
||||
$isMatched = ($appId === $matchedAppId);
|
||||
$appDomain = explode('/', $app['domains'][0])[0];
|
||||
$appFavicon = "https://icons.duckduckgo.com/ip2/{$appDomain}.ico";
|
||||
@endphp
|
||||
<div class="flex items-center gap-2.5 p-1.5 rounded-lg transition {{ $isMatched ? 'bg-emerald-500/10 text-emerald-800 dark:text-emerald-400 font-bold' : 'hover:bg-gray-100/60 dark:hover:bg-gray-800/50 text-gray-700 dark:text-gray-300' }}">
|
||||
<!-- Favicon Squircle -->
|
||||
<div class="w-7 h-7 rounded-[6px] bg-white dark:bg-gray-800 flex items-center justify-center p-1.5 flex-shrink-0">
|
||||
<img src="{{ $appFavicon }}" alt="{{ $app['name'] }}" class="w-4 h-4 object-contain" onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'">
|
||||
|
||||
<div @class([
|
||||
'app-linking-app',
|
||||
'app-linking-app--matched' => $isMatched,
|
||||
])>
|
||||
<div class="app-linking-app-icon-wrap">
|
||||
<img
|
||||
src="{{ $appFavicon }}"
|
||||
alt=""
|
||||
class="app-linking-app-icon"
|
||||
loading="lazy"
|
||||
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
|
||||
>
|
||||
@if ($isMatched)
|
||||
<span class="app-linking-app-check" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<span class="text-xs font-semibold truncate">
|
||||
{{ $app['name'] }}
|
||||
</span>
|
||||
@if ($isMatched)
|
||||
<span class="ml-auto flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 text-white text-[8px] font-bold shrink-0">
|
||||
✓
|
||||
</span>
|
||||
@endif
|
||||
<span class="app-linking-app-name">{{ $app['name'] }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Help footer -->
|
||||
<p class="text-[11px] text-gray-450 dark:text-gray-500 border-t border-gray-200 dark:border-gray-800 pt-3 leading-relaxed">
|
||||
<p class="app-linking-footnote">
|
||||
{{ __('filament-short-url::default.app_linking_supported_apps_helper') }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<x-dynamic-component
|
||||
:component="$getFieldWrapperView()"
|
||||
:field="$field"
|
||||
>
|
||||
@php
|
||||
$targetName = str_replace('_presets', '', $field->getName());
|
||||
$targetStatePath = str_replace($field->getName(), $targetName, $getStatePath());
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$targetStatePath}')") }},
|
||||
presets: ['#000000', '#dc2626', '#ea580c', '#ec4899', '#f59e0b', '#16a34a', '#2563eb', '#7c3aed'],
|
||||
get activePreset() {
|
||||
if (!this.state) return null;
|
||||
const normalizedState = this.state.toLowerCase();
|
||||
return this.presets.find(p => p.toLowerCase() === normalizedState) || null;
|
||||
}
|
||||
}"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<template x-for="color in presets" :key="color">
|
||||
<button type="button"
|
||||
x-on:click="state = color"
|
||||
:style="{ backgroundColor: color }"
|
||||
class="w-7 h-7 rounded-full border border-neutral-200 dark:border-neutral-800 shadow-sm flex items-center justify-center cursor-pointer transition-all hover:scale-105 relative focus:outline-none"
|
||||
:class="activePreset === color ? 'ring-2 ring-neutral-950 dark:ring-white ring-offset-2 dark:ring-offset-neutral-900' : ''"
|
||||
:title="color"
|
||||
>
|
||||
<!-- Checkmark icon visible when selected -->
|
||||
<template x-if="activePreset === color">
|
||||
<svg class="w-3.5 h-3.5" :class="color.toLowerCase() === '#f59e0b' ? 'text-black' : 'text-white'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,47 @@
|
||||
<x-dynamic-component
|
||||
:component="$getFieldWrapperView()"
|
||||
:field="$field"
|
||||
>
|
||||
<div
|
||||
x-data="{
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }},
|
||||
presets: ['#000000', '#dc2626', '#ea580c', '#ec4899', '#f59e0b', '#16a34a', '#2563eb', '#7c3aed'],
|
||||
get activePreset() {
|
||||
if (!this.state) return null;
|
||||
const normalizedState = this.state.toLowerCase();
|
||||
return this.presets.find(p => p.toLowerCase() === normalizedState) || null;
|
||||
}
|
||||
}"
|
||||
class="flex flex-row items-center gap-4 mt-2"
|
||||
>
|
||||
<!-- Custom Color Input Box -->
|
||||
<div class="relative flex items-center border border-neutral-300 dark:border-neutral-700 rounded-lg overflow-hidden bg-white dark:bg-neutral-900 shadow-sm" style="width: 145px; height: 36px;">
|
||||
<!-- Color block (left) -->
|
||||
<div :style="{ backgroundColor: state || '#000000' }" class="w-10 h-full cursor-pointer relative flex-shrink-0 border-r border-neutral-300 dark:border-neutral-700">
|
||||
<input type="color" x-model="state" class="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||
</div>
|
||||
<!-- Hex text input (right) -->
|
||||
<input type="text" x-model="state" placeholder="#000000" class="w-full h-full px-2 text-xs bg-transparent border-none outline-none focus:ring-0 text-neutral-800 dark:text-neutral-200 text-center font-mono" />
|
||||
</div>
|
||||
|
||||
<!-- Presets Row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<template x-for="color in presets" :key="color">
|
||||
<button type="button"
|
||||
x-on:click="state = color"
|
||||
:style="{ backgroundColor: color }"
|
||||
class="w-6.5 h-6.5 rounded-full border border-neutral-200 dark:border-neutral-800 shadow-sm flex items-center justify-center cursor-pointer transition-all hover:scale-105 relative focus:outline-none"
|
||||
:class="activePreset === color ? 'ring-2 ring-neutral-950 dark:ring-white ring-offset-2 dark:ring-offset-neutral-900' : ''"
|
||||
:title="color"
|
||||
>
|
||||
<!-- Checkmark icon visible when selected -->
|
||||
<template x-if="activePreset === color">
|
||||
<svg class="w-3 h-3" :class="color.toLowerCase() === '#f59e0b' ? 'text-black' : 'text-white'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
216
resources/views/forms/components/number-stepper.blade.php
Normal file
@@ -0,0 +1,216 @@
|
||||
@php
|
||||
$statePath = $getStatePath();
|
||||
$minValue = $getMinValue();
|
||||
$maxValue = $getMaxValue();
|
||||
$step = $getStep();
|
||||
$isInteger = $isInteger();
|
||||
$isNullable = $isNullable();
|
||||
$isDisabled = $isDisabled();
|
||||
$variant = $getVariant();
|
||||
$size = $getSize();
|
||||
$displaySuffix = $getDisplaySuffix();
|
||||
$nullLabel = $getNullLabel() ?? '—';
|
||||
|
||||
$containerClasses = match ($size) {
|
||||
'sm' => 'h-8 gap-0.5 p-0.5',
|
||||
'lg' => 'h-12 gap-1.5 p-1.5',
|
||||
default => 'h-10 gap-1 p-1',
|
||||
};
|
||||
|
||||
$buttonSizeClasses = match ($size) {
|
||||
'sm' => 'size-7',
|
||||
'lg' => 'size-10',
|
||||
default => 'size-8',
|
||||
};
|
||||
|
||||
$valueClasses = match ($size) {
|
||||
'sm' => 'min-w-14 px-1 text-xs',
|
||||
'lg' => 'min-w-20 px-2 text-base',
|
||||
default => 'min-w-16 px-1.5 text-sm',
|
||||
};
|
||||
|
||||
$iconClasses = match ($size) {
|
||||
'sm' => 'size-3',
|
||||
'lg' => 'size-5',
|
||||
default => 'size-4',
|
||||
};
|
||||
|
||||
$buttonVariantClasses = match ($variant) {
|
||||
'secondary' => 'bg-primary-50 text-primary-600 shadow-sm hover:bg-primary-100 disabled:opacity-40 dark:bg-primary-500/10 dark:text-primary-400 dark:hover:bg-primary-500/20',
|
||||
'tertiary' => 'bg-gray-200/80 text-gray-700 shadow-sm hover:bg-gray-300/80 disabled:opacity-40 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15',
|
||||
'outline' => 'border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-40 dark:border-white/20 dark:bg-transparent dark:text-gray-200 dark:hover:bg-white/5',
|
||||
default => 'bg-primary-600 text-white shadow-sm hover:bg-primary-500 disabled:opacity-40 dark:bg-primary-500 dark:hover:bg-primary-400',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$getFieldWrapperView()"
|
||||
:field="$field"
|
||||
>
|
||||
<div
|
||||
x-data="{
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
|
||||
min: @js($minValue),
|
||||
max: @js($maxValue),
|
||||
step: @js($step),
|
||||
integer: @js($isInteger),
|
||||
nullable: @js($isNullable),
|
||||
disabled: @js($isDisabled),
|
||||
nullLabel: @js($nullLabel),
|
||||
suffix: @js($displaySuffix),
|
||||
normalize(value) {
|
||||
if (value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
|
||||
if (Number.isNaN(numeric)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.integer ? Math.round(numeric) : numeric;
|
||||
},
|
||||
get numericState() {
|
||||
return this.normalize(this.state);
|
||||
},
|
||||
get hasValue() {
|
||||
return this.numericState !== null;
|
||||
},
|
||||
get displayValue() {
|
||||
if (! this.hasValue) {
|
||||
return this.nullLabel;
|
||||
}
|
||||
|
||||
return String(this.numericState);
|
||||
},
|
||||
get canDecrement() {
|
||||
if (this.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! this.hasValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.min === null) {
|
||||
return this.nullable || this.numericState > 0;
|
||||
}
|
||||
|
||||
if (this.nullable && this.numericState <= this.min) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.numericState > this.min;
|
||||
},
|
||||
get canIncrement() {
|
||||
if (this.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! this.hasValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.max === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.numericState < this.max;
|
||||
},
|
||||
decrement() {
|
||||
if (! this.canDecrement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! this.hasValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.nullable && this.min !== null && this.numericState <= this.min) {
|
||||
this.state = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let next = this.numericState - this.step;
|
||||
|
||||
if (this.min !== null) {
|
||||
next = Math.max(next, this.min);
|
||||
}
|
||||
|
||||
this.state = this.integer ? Math.round(next) : next;
|
||||
},
|
||||
increment() {
|
||||
if (! this.canIncrement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let next = this.hasValue
|
||||
? this.numericState + this.step
|
||||
: (this.min ?? this.step);
|
||||
|
||||
if (this.max !== null) {
|
||||
next = Math.min(next, this.max);
|
||||
}
|
||||
|
||||
this.state = this.integer ? Math.round(next) : next;
|
||||
},
|
||||
}"
|
||||
@class([
|
||||
'fsu-number-stepper inline-flex w-fit max-w-max shrink-0 justify-self-start items-center rounded-full bg-gray-100 dark:bg-white/10',
|
||||
$containerClasses,
|
||||
'opacity-60' => $isDisabled,
|
||||
])
|
||||
role="group"
|
||||
aria-label="{{ $getLabel() }}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@class([
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full transition duration-75 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed',
|
||||
$buttonSizeClasses,
|
||||
$buttonVariantClasses,
|
||||
])
|
||||
x-on:click="decrement()"
|
||||
x-bind:disabled="! canDecrement"
|
||||
x-bind:aria-disabled="! canDecrement"
|
||||
aria-label="{{ __('filament-short-url::default.number_stepper_decrease') }}"
|
||||
>
|
||||
<svg @class([$iconClasses]) viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
@class([
|
||||
'flex items-center justify-center gap-1 font-semibold tabular-nums text-gray-950 dark:text-white',
|
||||
$valueClasses,
|
||||
])
|
||||
>
|
||||
<span x-text="displayValue"></span>
|
||||
<span
|
||||
x-show="hasValue && suffix"
|
||||
x-text="suffix"
|
||||
class="font-medium text-gray-500 dark:text-gray-400"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@class([
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full transition duration-75 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed',
|
||||
$buttonSizeClasses,
|
||||
$buttonVariantClasses,
|
||||
])
|
||||
x-on:click="increment()"
|
||||
x-bind:disabled="! canIncrement"
|
||||
x-bind:aria-disabled="! canIncrement"
|
||||
aria-label="{{ __('filament-short-url::default.number_stepper_increase') }}"
|
||||
>
|
||||
<svg @class([$iconClasses]) viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
169
resources/views/forms/components/segment-control.blade.php
Normal file
@@ -0,0 +1,169 @@
|
||||
@php
|
||||
use Illuminate\Support\Js;
|
||||
|
||||
$statePath = $getStatePath();
|
||||
$options = $getNormalizedOptions();
|
||||
$optionKeys = array_keys($options);
|
||||
$size = $getSize();
|
||||
$variant = $getVariant();
|
||||
$hasSeparators = $hasSeparators();
|
||||
$isFullWidth = $isFullWidth();
|
||||
$isIconOnly = $isIconOnly();
|
||||
$expandSelectedLabel = $shouldExpandSelectedLabel();
|
||||
$isDisabled = $isDisabled();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$getFieldWrapperView()"
|
||||
:field="$field"
|
||||
>
|
||||
<div
|
||||
x-data="{
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
|
||||
optionKeys: {{ Js::from(array_values($optionKeys)) }},
|
||||
disabledOptions: {{ Js::from(collect($options)->mapWithKeys(fn (array $option, string | int $key): array => [(string) $key => $option['disabled']])->all()) }},
|
||||
separators: @js($hasSeparators),
|
||||
disabled: @js($isDisabled),
|
||||
indicatorStyle: '',
|
||||
resizeObserver: null,
|
||||
normalize(value) {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
},
|
||||
isSelected(value) {
|
||||
return this.normalize(this.state) === this.normalize(value);
|
||||
},
|
||||
isOptionDisabled(value) {
|
||||
return this.disabledOptions[this.normalize(value)] ?? false;
|
||||
},
|
||||
canSelect(value) {
|
||||
return ! this.disabled && ! this.isOptionDisabled(value);
|
||||
},
|
||||
select(value) {
|
||||
if (! this.canSelect(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = value;
|
||||
this.$nextTick(() => this.updateIndicator());
|
||||
},
|
||||
selectedIndex() {
|
||||
const current = this.normalize(this.state);
|
||||
|
||||
return this.optionKeys.findIndex((key) => this.normalize(key) === current);
|
||||
},
|
||||
showSeparator(separatorIndex) {
|
||||
if (! this.separators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedIndex = this.selectedIndex();
|
||||
|
||||
if (selectedIndex === -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return separatorIndex !== selectedIndex - 1 && separatorIndex !== selectedIndex;
|
||||
},
|
||||
updateIndicator() {
|
||||
const track = this.$refs.track;
|
||||
if (! track) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = track.querySelector('[data-segment-selected=true]');
|
||||
if (! selected) {
|
||||
this.indicatorStyle = 'opacity: 0;';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.indicatorStyle =
|
||||
'width: ' + selected.offsetWidth + 'px;' +
|
||||
'height: ' + selected.offsetHeight + 'px;' +
|
||||
'transform: translate3d(' + selected.offsetLeft + 'px, ' + selected.offsetTop + 'px, 0);' +
|
||||
'opacity: 1;';
|
||||
},
|
||||
init() {
|
||||
this.$watch('state', () => this.$nextTick(() => this.updateIndicator()));
|
||||
this.$nextTick(() => this.updateIndicator());
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this.updateIndicator());
|
||||
this.resizeObserver.observe(this.$refs.track);
|
||||
},
|
||||
}"
|
||||
x-init="init()"
|
||||
@class([
|
||||
'fsu-segment-control',
|
||||
'w-full' => $isFullWidth,
|
||||
'opacity-60 pointer-events-none' => $isDisabled,
|
||||
])
|
||||
role="radiogroup"
|
||||
aria-label="{{ $getLabel() }}"
|
||||
>
|
||||
<div
|
||||
x-ref="track"
|
||||
@class([
|
||||
'fsu-segment-track',
|
||||
'fsu-segment-track--'.$size,
|
||||
'fsu-segment-track--ghost' => $variant === 'ghost',
|
||||
])
|
||||
>
|
||||
<div
|
||||
x-ref="indicator"
|
||||
aria-hidden="true"
|
||||
@class([
|
||||
'fsu-segment-indicator',
|
||||
'fsu-segment-indicator--ghost' => $variant === 'ghost',
|
||||
])
|
||||
:style="indicatorStyle"
|
||||
></div>
|
||||
|
||||
@foreach ($options as $value => $option)
|
||||
@if (! $loop->first && $hasSeparators)
|
||||
<span
|
||||
x-show="showSeparator({{ $loop->index - 1 }})"
|
||||
x-cloak
|
||||
class="fsu-segment-separator"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
@endif
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
@class([
|
||||
'fsu-segment-item',
|
||||
'fsu-segment-item--'.$size,
|
||||
])
|
||||
data-segment-value="{{ $value }}"
|
||||
x-bind:data-segment-selected="isSelected(@js($value)) ? 'true' : 'false'"
|
||||
x-bind:aria-checked="isSelected(@js($value)) ? 'true' : 'false'"
|
||||
x-bind:disabled="disabled || isOptionDisabled(@js($value))"
|
||||
x-on:click="select(@js($value))"
|
||||
@if (filled($option['tooltip'] ?? null))
|
||||
x-tooltip="{ content: @js($option['tooltip']), theme: $store.theme }"
|
||||
@endif
|
||||
>
|
||||
@if ($option['icon'])
|
||||
<x-filament::icon :icon="$option['icon']" />
|
||||
@endif
|
||||
|
||||
@if ($isIconOnly)
|
||||
<span class="sr-only">{{ $option['label'] }}</span>
|
||||
@elseif ($expandSelectedLabel)
|
||||
<span
|
||||
x-show="isSelected(@js($value))"
|
||||
x-cloak
|
||||
>{{ $option['label'] }}</span>
|
||||
@else
|
||||
<span>{{ $option['label'] }}</span>
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -134,7 +134,7 @@
|
||||
<div class="w-1.5" x-show="index > 0"></div>
|
||||
|
||||
<!-- Segment Box -->
|
||||
<div class="flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs shadow-sm select-none">
|
||||
<div class="traffic-splitter-segment flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs select-none">
|
||||
<span class="text-xs font-semibold text-neutral-900 dark:text-neutral-100" x-text="index + 1"></span>
|
||||
<span class="font-medium text-neutral-600 dark:text-neutral-400" x-show="item.weight >= 12" x-text="item.weight + '%'"></span>
|
||||
</div>
|
||||
|
||||
216
resources/views/modals/share-after-create.blade.php
Normal file
@@ -0,0 +1,216 @@
|
||||
{{--
|
||||
Share After Create Modal
|
||||
Variables available:
|
||||
- $shortUrl string Full short URL (e.g. https://wy.test/s/abc)
|
||||
- $qrTargetUrl string Short URL with ?source=qr appended
|
||||
- $destHost string Host of destination URL (for favicon)
|
||||
- $urlKey string URL key (used for QR download filenames)
|
||||
- $eid string Unique element ID prefix
|
||||
- $qrOptions array QR styling options for QRCodeStyling
|
||||
- $successTitle string Modal heading text
|
||||
- $successSubtitle string Modal subtitle text
|
||||
- $successHelper string Modal helper text
|
||||
- $downloadSvgText string Download SVG button label
|
||||
- $downloadPngText string Download PNG button label
|
||||
- $closeButtonText string Close button title
|
||||
- $copyLinkText string Copy link button title
|
||||
- $qrCodeText string QR code button title
|
||||
- $openLinkText string Open link button title
|
||||
- $dontShowAgainText string Don't show again button label
|
||||
--}}
|
||||
|
||||
<div x-data x-init="if(localStorage.getItem('fsu:hide-share-modal')==='1'){ $nextTick(()=>$wire.unmountAction()) }">
|
||||
|
||||
{{-- Close Button (x) --}}
|
||||
<button type="button"
|
||||
x-on:click="event.preventDefault(); event.stopPropagation(); const f=$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){$dispatch('close-modal',{id:m[1]})}else{$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{{ $closeButtonText }}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#6b7280;margin:0 0 6px">{{ $successSubtitle }}</p>
|
||||
<h2 style="font-size:23px;font-weight:700;color:#111827;margin:0 0 6px;line-height:1.25">{{ $successTitle }}</h2>
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{{ $successHelper }}</p>
|
||||
</div>
|
||||
|
||||
{{-- URL pill --}}
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{{ $destHost }}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $shortUrl }}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
{{-- Copy button --}}
|
||||
<button id="{{ $eid }}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{{ $shortUrl }}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{{ $eid }}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ $copyLinkText }}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
|
||||
{{-- QR toggle button --}}
|
||||
<button id="{{ $eid }}_qr_btn" type="button" data-qr-options='@json($qrOptions)'
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const container = document.getElementById('{{ $eid }}_qr_container');
|
||||
const canvas = document.getElementById('{{ $eid }}_qr_canvas');
|
||||
if (container.style.display === 'none') {
|
||||
container.style.display = 'block';
|
||||
const loadScript = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = '/js/janczakb/filament-short-url/qr-code-styling.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
};
|
||||
loadScript().then(() => {
|
||||
if (!canvas.innerHTML) {
|
||||
const opts = JSON.parse(this.getAttribute('data-qr-options'));
|
||||
opts.data = '{{ $qrTargetUrl }}';
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200; cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
cx.drawImage(img, (1200 - img.width * scale) / 2, (1200 - img.height * scale) / 2, img.width * scale, img.height * scale);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.rect(offset, offset, targetDim, targetDim);
|
||||
}
|
||||
cx.clip();
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
cx.drawImage(img, (1200 - img.width * scale) / 2, (1200 - img.height * scale) / 2, img.width * scale, img.height * scale);
|
||||
}
|
||||
cx.restore();
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
window['qr_{{ $eid }}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{{ $eid }}'].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_{{ $eid }}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{{ $eid }}'].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ $qrCodeText }}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- Open link --}}
|
||||
<a href="{{ $shortUrl }}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{{ $openLinkText }}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- QR Code Container (toggled) --}}
|
||||
<div id="{{ $eid }}_qr_container" style="display:none;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{{ $eid }}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ $eid }}']?.download({ name: '{{ $urlKey }}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadSvgText }}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{{ $eid }}']?.download({ name: '{{ $urlKey }}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{{ $downloadPngText }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Social sharing --}}
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin:18px 0 0;flex-wrap:wrap">
|
||||
<a href="mailto:?body={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="Email"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#374151" 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>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="LinkedIn"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#0077b5" 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>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="Facebook"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#1877f2" 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>
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/tweet?url={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="X (Twitter)"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:19px;height:19px;color:#0f1419" 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>
|
||||
</a>
|
||||
<a href="https://api.whatsapp.com/send?text={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="WhatsApp"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#25d366" 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>
|
||||
</a>
|
||||
<a href="https://t.me/share/url?url={{ urlencode($shortUrl) }}" target="_blank" rel="noopener" title="Telegram"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#229ED9" fill="currentColor" viewBox="0 0 24 24"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Don't show again --}}
|
||||
<div style="display:flex;justify-content:center;margin:20px 0 2px">
|
||||
<button type="button"
|
||||
x-on:click="localStorage.setItem('fsu:hide-share-modal', '1'); const f=$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){$dispatch('close-modal',{id:m[1]})}else{$wire.unmountAction()}"
|
||||
style="font-size:12px;color:#9ca3af;background:none;border:none;cursor:pointer;text-decoration:underline;transition:color .15s"
|
||||
onmouseover="this.style.color='#4b5563'"
|
||||
onmouseout="this.style.color='#9ca3af'">
|
||||
{{ $dontShowAgainText }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
33
resources/views/panel/footer.blade.php
Normal file
@@ -0,0 +1,33 @@
|
||||
@php
|
||||
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
|
||||
|
||||
$version = FilamentShortUrlPlugin::version();
|
||||
@endphp
|
||||
|
||||
<footer class="filamentshortUrl-panel-footer" aria-label="Plugin footer">
|
||||
<div class="filamentshortUrl-panel-footer__card">
|
||||
<span class="filamentshortUrl-panel-footer__title">Filament Short URL</span>
|
||||
<span class="filamentshortUrl-panel-footer__version">v{{ $version }}</span>
|
||||
<span class="filamentshortUrl-panel-footer__sep" aria-hidden="true">·</span>
|
||||
<span class="filamentshortUrl-panel-footer__meta">
|
||||
{{ FilamentShortUrlPlugin::POWERED_BY_LABEL }}
|
||||
<a
|
||||
href="{{ FilamentShortUrlPlugin::AUTHOR_URL }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="filamentshortUrl-panel-footer__link"
|
||||
>
|
||||
{{ FilamentShortUrlPlugin::AUTHOR_HANDLE }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="filamentshortUrl-panel-footer__sep" aria-hidden="true">·</span>
|
||||
<a
|
||||
href="{{ FilamentShortUrlPlugin::PACKAGE_URL }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="filamentshortUrl-panel-footer__link filamentshortUrl-panel-footer__package"
|
||||
>
|
||||
{{ FilamentShortUrlPlugin::PACKAGE_NAME }}
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -174,7 +174,7 @@
|
||||
});
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.replace("{!! addslashes($destination) !!}");
|
||||
window.location.replace(@json($destination));
|
||||
}, 250);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
48
resources/views/public-stats-layout.blade.php
Normal file
@@ -0,0 +1,48 @@
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>@yield('title', $siteName)</title>
|
||||
<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">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen font-sans antialiased text-neutral-900 dark:text-white">
|
||||
<div class="mx-auto w-full max-w-3xl px-6 py-10">
|
||||
<header class="mb-8 flex flex-col items-center text-center">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="mb-4 h-[52px] w-auto object-contain" />
|
||||
@else
|
||||
<span class="mb-2 text-2xl font-extrabold tracking-tight">{{ $siteName }}</span>
|
||||
@endif
|
||||
@hasSection('subtitle')
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">@yield('subtitle')</p>
|
||||
@endif
|
||||
</header>
|
||||
|
||||
@yield('content')
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
27
resources/views/public-stats-password.blade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
@extends('filament-short-url::public-stats-layout')
|
||||
|
||||
@section('title', __('filament-short-url::default.public_stats_password_title'))
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto flex w-full max-w-[360px] flex-col gap-6">
|
||||
<div class="text-center">
|
||||
<p class="text-xl font-medium">{{ __('filament-short-url::default.public_stats_password_title') }}</p>
|
||||
<p class="mt-1 text-sm text-neutral-500">{{ __('filament-short-url::default.public_stats_password_description') }}</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="flex flex-col gap-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium">{{ __('filament-short-url::default.password_placeholder') }}</label>
|
||||
<input type="password" name="password" id="password" required autofocus
|
||||
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3.5 py-2.5 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900">
|
||||
@if(isset($errors) && $errors->has('password'))
|
||||
<p class="mt-1 text-sm text-red-600">{{ $errors->first('password') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<button type="submit" class="rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-950">
|
||||
{{ __('filament-short-url::default.public_stats_password_submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
12
resources/views/public-stats-rate-limited.blade.php
Normal file
@@ -0,0 +1,12 @@
|
||||
@extends('filament-short-url::public-stats-layout')
|
||||
|
||||
@section('title', __('filament-short-url::default.public_stats_rate_limited'))
|
||||
|
||||
@section('content')
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-center dark:border-amber-900 dark:bg-amber-950/40">
|
||||
<p class="text-lg font-semibold">{{ __('filament-short-url::default.public_stats_rate_limited') }}</p>
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{{ __('filament-short-url::default.public_stats_retry_in', ['seconds' => $retryAfter]) }}
|
||||
</p>
|
||||
</div>
|
||||
@endsection
|
||||
69
resources/views/public-stats.blade.php
Normal file
@@ -0,0 +1,69 @@
|
||||
@extends('filament-short-url::public-stats-layout')
|
||||
|
||||
@section('title', __('filament-short-url::default.public_stats_page_title'))
|
||||
|
||||
@section('subtitle')
|
||||
{{ __('filament-short-url::default.public_stats_page_subtitle', ['key' => $shortUrl->url_key]) }}
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<form method="GET" class="mb-6 flex flex-wrap items-end gap-3 rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
|
||||
<div class="min-w-[140px] flex-1">
|
||||
<label for="date_from" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_date_from') }}</label>
|
||||
<input type="date" name="date_from" id="date_from" value="{{ $dateFrom }}"
|
||||
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900">
|
||||
</div>
|
||||
<div class="min-w-[140px] flex-1">
|
||||
<label for="date_to" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_date_to') }}</label>
|
||||
<input type="date" name="date_to" id="date_to" value="{{ $dateTo }}"
|
||||
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900">
|
||||
</div>
|
||||
<button type="submit" class="rounded-lg bg-neutral-900 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-950 dark:hover:bg-neutral-200">
|
||||
{{ __('filament-short-url::default.public_stats_apply_filter') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
@foreach ([
|
||||
'totalVisits' => __('filament-short-url::default.public_stats_total_visits'),
|
||||
'uniqueVisits' => __('filament-short-url::default.public_stats_unique_visits'),
|
||||
'visitsToday' => __('filament-short-url::default.public_stats_today'),
|
||||
'visitsThisWeek' => __('filament-short-url::default.public_stats_this_week'),
|
||||
'visitsThisMonth' => __('filament-short-url::default.public_stats_this_month'),
|
||||
'qrScans' => __('filament-short-url::default.public_stats_qr_scans'),
|
||||
] as $metric => $label)
|
||||
<div class="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ $label }}</p>
|
||||
<p class="mt-2 text-2xl font-bold tabular-nums">{{ number_format((int) ($stats[$metric] ?? 0)) }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_visits_by_day') }}</h2>
|
||||
|
||||
@php($visitsByDay = $stats['visitsByDay'] ?? [])
|
||||
@if (empty($visitsByDay))
|
||||
<p class="text-sm text-neutral-500">{{ __('filament-short-url::default.public_stats_no_data') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 text-left text-neutral-500 dark:border-neutral-800">
|
||||
<th class="py-2 pr-4 font-semibold">{{ __('filament-short-url::default.public_stats_day') }}</th>
|
||||
<th class="py-2 font-semibold">{{ __('filament-short-url::default.public_stats_visits') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($visitsByDay as $day => $count)
|
||||
<tr class="border-b border-neutral-100 dark:border-neutral-900">
|
||||
<td class="py-2 pr-4">{{ $day }}</td>
|
||||
<td class="py-2 tabular-nums">{{ number_format((int) $count) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
50
resources/views/qr-preview-canvas.blade.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<div
|
||||
x-data="shortUrlQrDesignerPreview({
|
||||
url: @js($shortUrl)
|
||||
})"
|
||||
class="qr-preview-sticky"
|
||||
>
|
||||
<div class="qr-preview-card">
|
||||
<div style="width:100%;display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-shrink:0">
|
||||
<span style="font-size:12px;font-weight:700;color:#a1a1aa;text-transform:uppercase;letter-spacing:0.05em">{{ __('filament-short-url::default.qr_label_live_preview') }}</span>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button type="button" x-on:click="download('png')" class="qr-dl-btn" style="padding:4px 10px;font-size:11px;border-radius:6px">
|
||||
{{ __('filament-short-url::default.qr_label_png') }}
|
||||
</button>
|
||||
<button type="button" x-on:click="download('svg')" class="qr-dl-btn" style="padding:4px 10px;font-size:11px;border-radius:6px">
|
||||
{{ __('filament-short-url::default.qr_label_svg') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;display:flex;align-items:center;justify-content:center;width:100%">
|
||||
<div :class="bgTransparent ? 'qr-checker' : ''"
|
||||
:style="{
|
||||
width: '100%',
|
||||
maxWidth: '280px',
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '24px',
|
||||
position: 'relative',
|
||||
background: bgTransparent ? '' : bgColor,
|
||||
}">
|
||||
{{-- Spinner --}}
|
||||
<div x-show="!qrInstance"
|
||||
style="width:32px;height:32px;border:3px solid #e5e7eb;
|
||||
border-top-color:#6366f1;border-radius:50%;
|
||||
animation:qr-spin 0.8s linear infinite">
|
||||
</div>
|
||||
{{-- QR canvas --}}
|
||||
<div wire:ignore x-ref="qrCanvas" class="qr-preview-wrapper" style="line-height:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-center font-mono" style="font-size:11px;color:#71717a;width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:14px;flex-shrink:0">
|
||||
<span x-text="url"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
97
resources/views/redirect-html.blade.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $ogMeta['title'] }}</title>
|
||||
|
||||
{{-- Robots Search Indexing configuration --}}
|
||||
@if($shortUrl->do_index)
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="{{ $ogMeta['canonical_url'] }}">
|
||||
@else
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
@endif
|
||||
|
||||
{{-- Open Graph / Facebook Metadata --}}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ $ogMeta['short_url'] }}">
|
||||
<meta property="og:site_name" content="{{ $ogMeta['site_name'] }}">
|
||||
<meta property="og:title" content="{{ $ogMeta['title'] }}">
|
||||
@if($ogMeta['description'])
|
||||
<meta property="og:description" content="{{ $ogMeta['description'] }}">
|
||||
@endif
|
||||
@if($ogMeta['image_url'])
|
||||
<meta property="og:image" content="{{ $ogMeta['image_url'] }}">
|
||||
@if($ogMeta['image_width'] && $ogMeta['image_height'])
|
||||
<meta property="og:image:width" content="{{ $ogMeta['image_width'] }}">
|
||||
<meta property="og:image:height" content="{{ $ogMeta['image_height'] }}">
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- Twitter Metadata --}}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="{{ $ogMeta['short_url'] }}">
|
||||
<meta name="twitter:title" content="{{ $ogMeta['title'] }}">
|
||||
@if($ogMeta['description'])
|
||||
<meta name="twitter:description" content="{{ $ogMeta['description'] }}">
|
||||
@endif
|
||||
@if($ogMeta['image_url'])
|
||||
<meta name="twitter:image" content="{{ $ogMeta['image_url'] }}">
|
||||
@endif
|
||||
|
||||
{{-- Human visitors only: bots must stay on this page to read OG tags --}}
|
||||
@if(!$shortUrl->is_cloaked && !$isBot)
|
||||
<meta http-equiv="refresh" content="0;url={{ e($destination) }}">
|
||||
<script>
|
||||
window.location.replace(@json($destination));
|
||||
</script>
|
||||
@endif
|
||||
|
||||
@if($shortUrl->is_cloaked)
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
z-index: 999999;
|
||||
}
|
||||
</style>
|
||||
@endif
|
||||
</head>
|
||||
<body class="h-full bg-white text-neutral-800 antialiased font-sans flex items-center justify-center">
|
||||
|
||||
@if($shortUrl->is_cloaked)
|
||||
{{-- Link Cloaking Iframe --}}
|
||||
<iframe src="{{ e($destination) }}" title="{{ $ogMeta['title'] }}"></iframe>
|
||||
@elseif(!$isBot)
|
||||
{{-- Standard JS redirect page fallback for human visitors --}}
|
||||
<div class="text-center p-6 max-w-sm flex flex-col items-center gap-4 select-none">
|
||||
<div class="flex items-center justify-center gap-1.5" aria-hidden="true">
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-300 dark:bg-neutral-600 animate-bounce [animation-delay:-0.3s]"></span>
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-400 dark:bg-neutral-500 animate-bounce [animation-delay:-0.15s]"></span>
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
|
||||
</div>
|
||||
<p class="text-lg font-medium text-neutral-600">{{ __('filament-short-url::default.redirect_html_redirecting') }}</p>
|
||||
<p class="text-xs text-neutral-400">If you are not redirected automatically, <a href="{{ e($destination) }}" class="text-primary-600 underline font-semibold hover:text-primary-500">click here</a>.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</body>
|
||||
</html>
|
||||
34
resources/views/sidebar/qr-preview.blade.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<div
|
||||
wire:ignore
|
||||
class="sidebar-qr-container"
|
||||
@qr-design-updated.window="const _d = Array.isArray($event.detail) ? $event.detail[0] : $event.detail; applyQrDesign(_d?.options, _d?.logo)"
|
||||
x-data="shortUrlQrPreview({
|
||||
options: @js($opts),
|
||||
logo: @js($currentLogo),
|
||||
domains: @js($domains),
|
||||
defaultDomain: @js($defaultDomain),
|
||||
routePrefix: @js(config('filament-short-url.route_prefix')),
|
||||
defaultUrlKey: @js($record ? $record->url_key : ''),
|
||||
protocol: @js(app()->isProduction() ? 'https' : (parse_url(config('app.url'), PHP_URL_SCHEME) ?: 'https'))
|
||||
})"
|
||||
>
|
||||
|
||||
|
||||
{{-- Label row matching "Folder" label style --}}
|
||||
<span class="fi-fo-field-wrp-label flex items-center gap-x-3 mb-1.5">
|
||||
<label class="fi-label text-sm font-semibold leading-6 text-gray-950 dark:text-white">
|
||||
{{ __('filament-short-url::default.action_qr') }}
|
||||
</label>
|
||||
</span>
|
||||
|
||||
{{-- Dotted Box Wrapper --}}
|
||||
<div class="w-full h-[94px] border border-neutral-200 dark:border-neutral-800 rounded-xl relative overflow-hidden qr-preview-dots-bg flex items-center justify-center">
|
||||
<!-- QR Code Canvas in the middle (64x64px) -->
|
||||
<div x-ref="sidebarQrCanvas" class="w-[64px] h-[64px] flex items-center justify-center bg-white rounded-lg p-1 shadow-sm border border-neutral-100 dark:border-neutral-800/60 overflow-hidden"></div>
|
||||
|
||||
{{-- Edit Button — positioned via inline style to guarantee correct offset --}}
|
||||
@if ($getAction('designQr'))
|
||||
{{ $getAction('designQr') }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
131
resources/views/sidebar/social-preview.blade.php
Normal file
@@ -0,0 +1,131 @@
|
||||
@php
|
||||
$ogTitle = $ogTitle ?? ($get('og_title') ?: null);
|
||||
$ogDescription = $ogDescription ?? ($get('og_description') ?: null);
|
||||
$ogImageUrl = $ogImageUrl ?? null;
|
||||
$isScraping = $isScraping ?? ($get('is_scraping') ?: false);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="sidebar-social-container"
|
||||
wire:key="fsu-social-preview-{{ md5(json_encode([$ogTitle, $ogDescription, $ogImageUrl, (bool) $isScraping])) }}"
|
||||
x-data="{ scraping: @js((bool) $isScraping), passwordProtected: @js((bool) $isPasswordProtected) }"
|
||||
x-on:fsu-scraping-start.window="scraping = true"
|
||||
x-on:fsu-scraping-end.window="scraping = false"
|
||||
x-on:fsu-password-protection-changed.window="passwordProtected = !!$event.detail.protected"
|
||||
x-on:fsu-og-image-updated.window="scraping = false"
|
||||
>
|
||||
|
||||
{{-- Header: Title only (no "i" icon) --}}
|
||||
<span class="fi-fo-field-wrp-label flex items-center gap-x-3 mb-1.5">
|
||||
<label class="fi-label text-sm font-semibold leading-6 text-gray-950 dark:text-white">
|
||||
{{ __('filament-short-url::default.live_social_preview') }}
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<div x-show="scraping" x-cloak>
|
||||
{{-- ===== LOADING / SCRAPING STATE ===== --}}
|
||||
<div class="relative w-full rounded-xl overflow-hidden border border-neutral-200 dark:border-neutral-700 qr-preview-dots-bg aspect-[16/10] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-neutral-100 dark:bg-neutral-800/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-indigo-600 dark:text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24" style="width: 32px !important; height: 32px !important;">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 space-y-2">
|
||||
<div class="h-4 bg-neutral-200 dark:bg-neutral-800 rounded animate-pulse w-3/4"></div>
|
||||
<div class="h-3.5 bg-neutral-200 dark:bg-neutral-800 rounded animate-pulse w-5/6"></div>
|
||||
<div class="h-3 bg-neutral-200 dark:bg-neutral-800/60 rounded animate-pulse w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!scraping && passwordProtected" x-cloak>
|
||||
{{-- ===== PASSWORD STATE ===== --}}
|
||||
<div class="relative w-full rounded-xl overflow-hidden bg-black aspect-[16/10]">
|
||||
{{-- Edit button --}}
|
||||
<button
|
||||
type="button"
|
||||
onclick="
|
||||
const tabs = document.querySelectorAll('[role=tab]');
|
||||
tabs.forEach(t => { if (t.textContent.trim().includes('SEO') || t.textContent.trim().includes('Social') || t.getAttribute('aria-label')?.includes('seo')) { t.click(); } });
|
||||
"
|
||||
class="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-white/90 dark:bg-neutral-900/90 border border-neutral-200 dark:border-neutral-700 shadow-sm flex items-center justify-center hover:bg-white dark:hover:bg-neutral-800 transition cursor-pointer"
|
||||
style="width: 32px !important; height: 32px !important;"
|
||||
title="{{ __('filament-short-url::default.action_edit') }}"
|
||||
>
|
||||
<svg class="w-4 h-4 text-neutral-500 dark:text-neutral-400" style="width: 20px !important; height: 20px !important;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- Lock icon centered --}}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-14 h-14 rounded-full bg-neutral-800/80 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Password caption --}}
|
||||
<div class="mt-2.5">
|
||||
<p class="text-sm font-bold text-neutral-800 dark:text-neutral-100">
|
||||
{{ __('filament-short-url::default.password_status_active') }}
|
||||
</p>
|
||||
<p class="text-[12px] text-neutral-500 dark:text-neutral-400 mt-0.5 leading-snug">
|
||||
{{ __('filament-short-url::default.password_prompt_preview_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!scraping && !passwordProtected" x-cloak>
|
||||
{{-- ===== DEFAULT / IMAGE STATE ===== --}}
|
||||
<div class="relative w-full rounded-xl overflow-hidden border border-neutral-200 dark:border-neutral-700 qr-preview-dots-bg aspect-[16/10] flex items-center justify-center">
|
||||
|
||||
{{-- Edit button (same style as QR preview: small, top-right) --}}
|
||||
<button
|
||||
type="button"
|
||||
onclick="
|
||||
const tabs = document.querySelectorAll('[role=tab]');
|
||||
tabs.forEach(t => { if (t.textContent.trim().includes('SEO') || t.textContent.trim().includes('Social') || t.getAttribute('aria-label')?.includes('seo')) { t.click(); } });
|
||||
"
|
||||
class="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-white/90 dark:bg-neutral-900/90 border border-neutral-200 dark:border-neutral-700 shadow-sm flex items-center justify-center hover:bg-white dark:hover:bg-neutral-800 transition cursor-pointer"
|
||||
style="width: 32px !important; height: 32px !important;"
|
||||
title="{{ __('filament-short-url::default.action_edit') }}"
|
||||
>
|
||||
<svg class="w-4 h-4 text-neutral-500 dark:text-neutral-400" style="width: 20px !important; height: 20px !important;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if ($ogImageUrl)
|
||||
{{-- OG Image preview --}}
|
||||
<img src="{{ $ogImageUrl }}" alt="OG Preview" class="absolute inset-0 w-full h-full object-cover">
|
||||
@else
|
||||
{{-- Empty state: photo icon + text --}}
|
||||
<div class="flex flex-col items-center gap-2 text-neutral-400 dark:text-neutral-500 select-none pointer-events-none">
|
||||
<svg class="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.3">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" stroke-width="1.3" stroke="currentColor" fill="none"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16l5-5a2 2 0 012.8 0L16 16m-2-2l1.5-1.5a2 2 0 012.8 0L20 14"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
<span class="text-[11px] font-medium text-center leading-snug">
|
||||
{{ __('filament-short-url::default.og_empty_state_hint') }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Title / Description caption --}}
|
||||
<div class="mt-2.5 space-y-0.5">
|
||||
<p class="text-sm font-bold {{ $ogTitle ? 'text-neutral-800 dark:text-neutral-100' : 'text-neutral-400 dark:text-neutral-600' }}">
|
||||
{{ $ogTitle ?: __('filament-short-url::default.og_title_placeholder') }}
|
||||
</p>
|
||||
<p class="text-[12px] {{ $ogDescription ? 'text-neutral-500 dark:text-neutral-400' : 'text-neutral-300 dark:text-neutral-600' }} leading-snug">
|
||||
{{ $ogDescription ?: __('filament-short-url::default.og_description_placeholder') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
44
resources/views/table/public-stats-url-field.blade.php
Normal file
@@ -0,0 +1,44 @@
|
||||
@php
|
||||
$publicStatsUrl = $record->getPublicStatsUrl();
|
||||
$copiedMsg = __('filament-short-url::default.public_stats_copied');
|
||||
$copyBtnText = __('filament-short-url::default.share_copy');
|
||||
$inputId = 'public_stats_url_'.$record->id;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2">
|
||||
@if (! $record->public_stats_enabled)
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ __('filament-short-url::default.public_stats_save_to_activate') }}
|
||||
</p>
|
||||
@else
|
||||
<p class="text-sm text-emerald-600 dark:text-emerald-400">
|
||||
{{ __('filament-short-url::default.public_stats_enabled_status') }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text"
|
||||
readonly
|
||||
value="{{ e($publicStatsUrl) }}"
|
||||
id="{{ $inputId }}"
|
||||
class="min-w-0 flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3.5 py-2.5 text-sm text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-100">
|
||||
|
||||
<button type="button"
|
||||
onclick="
|
||||
const input = document.getElementById('{{ $inputId }}');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value);
|
||||
if (typeof FilamentNotification !== 'undefined') {
|
||||
new FilamentNotification().title('{{ e($copiedMsg) }}').success().send();
|
||||
}
|
||||
"
|
||||
class="inline-flex flex-shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-950 dark:hover:bg-white">
|
||||
{{ $copyBtnText }}
|
||||
</button>
|
||||
|
||||
<a href="{{ $publicStatsUrl }}" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex flex-shrink-0 items-center justify-center rounded-lg border border-gray-300 px-3 py-2.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800">
|
||||
{{ __('filament-short-url::default.public_stats_open') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,17 +187,12 @@
|
||||
});
|
||||
},
|
||||
loadScript() {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s.src = '/js/janczakb/filament-short-url/qr-code-styling.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s2.onload = () => resolve();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,46 +1,12 @@
|
||||
@php
|
||||
$rawJson = '{
|
||||
"event": "visited",
|
||||
"timestamp": "2026-06-04T12:00:00+02:00",
|
||||
"short_url": {
|
||||
"id": 12,
|
||||
"destination_url": "https://example.com/some-page",
|
||||
"url_key": "promo26",
|
||||
"short_url": "https://yoursite.com/s/promo26",
|
||||
"total_visits": 150,
|
||||
"unique_visits": 120
|
||||
},
|
||||
"visit": {
|
||||
"id": 345,
|
||||
"visited_at": "2026-06-04T12:00:00+02:00",
|
||||
"device_type": "mobile",
|
||||
"browser": "Chrome",
|
||||
"browser_version": "120.0",
|
||||
"operating_system": "Android",
|
||||
"operating_system_version": "14",
|
||||
"country": "Poland",
|
||||
"country_code": "PL",
|
||||
"city": "Warsaw",
|
||||
"referer_url": "https://t.co/",
|
||||
"referer_host": "t.co",
|
||||
"utm_source": "twitter",
|
||||
"utm_medium": "social",
|
||||
"utm_campaign": "summer_sale",
|
||||
"utm_term": null,
|
||||
"utm_content": "banner_ad",
|
||||
"is_qr_scan": false,
|
||||
"browser_language": "pl"
|
||||
}
|
||||
}';
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WebhookPayloadExample;
|
||||
|
||||
$rawJson = $rawJson ?? WebhookPayloadExample::visitedEventSampleJson();
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2 mt-4">
|
||||
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
|
||||
{{ __('filament-short-url::default.webhook_show_payload') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
<div class="marketing-webhook-payload-wrap">
|
||||
<div
|
||||
x-data="{
|
||||
copied: false,
|
||||
rawJson: @js($rawJson),
|
||||
copy() {
|
||||
@@ -48,61 +14,23 @@
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 2000);
|
||||
}
|
||||
}"
|
||||
style="position: relative; overflow: hidden; border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #18181b; padding: 1.25rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);"
|
||||
}"
|
||||
class="marketing-webhook-payload-code"
|
||||
>
|
||||
<!-- Copy Button in Top Right -->
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copy"
|
||||
x-on:mouseenter="$el.style.backgroundColor='rgba(255, 255, 255, 0.15)'; $el.style.color='#ffffff';"
|
||||
x-on:mouseleave="$el.style.backgroundColor='rgba(255, 255, 255, 0.08)'; $el.style.color='#a1a1aa';"
|
||||
style="position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; height: 2rem; width: 2rem; border-radius: 0.5rem; background-color: rgba(255, 255, 255, 0.08); color: #a1a1aa; border: none; cursor: pointer; transition: all 0.2s;"
|
||||
title="Copy payload to clipboard"
|
||||
class="marketing-webhook-payload-copy"
|
||||
title="{{ __('filament-short-url::default.webhook_payload_copy') }}"
|
||||
>
|
||||
<!-- Copy Icon -->
|
||||
<svg x-show="!copied" style="height: 1rem; width: 1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
<!-- Check Icon -->
|
||||
<svg x-show="copied" x-cloak style="height: 1rem; width: 1rem; color: #34d399;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Syntax Highlighted Payload -->
|
||||
<pre style="margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #d4d4d8; overflow-x: auto; white-space: pre-wrap; word-break: break-all; padding-right: 2.5rem;"><code style="font-family: inherit; font-size: inherit; color: inherit;">{
|
||||
<span style="color: #f43f5e;">"event"</span>: <span style="color: #eab308;">"visited"</span>,
|
||||
<span style="color: #f43f5e;">"timestamp"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
|
||||
<span style="color: #f43f5e;">"short_url"</span>: {
|
||||
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">12</span>,
|
||||
<span style="color: #f43f5e;">"destination_url"</span>: <span style="color: #eab308;">"https://example.com/some-page"</span>,
|
||||
<span style="color: #f43f5e;">"url_key"</span>: <span style="color: #eab308;">"promo26"</span>,
|
||||
<span style="color: #f43f5e;">"short_url"</span>: <span style="color: #eab308;">"https://yoursite.com/s/promo26"</span>,
|
||||
<span style="color: #f43f5e;">"total_visits"</span>: <span style="color: #c084fc;">150</span>,
|
||||
<span style="color: #f43f5e;">"unique_visits"</span>: <span style="color: #c084fc;">120</span>
|
||||
},
|
||||
<span style="color: #f43f5e;">"visit"</span>: {
|
||||
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">345</span>,
|
||||
<span style="color: #f43f5e;">"visited_at"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
|
||||
<span style="color: #f43f5e;">"device_type"</span>: <span style="color: #eab308;">"mobile"</span>,
|
||||
<span style="color: #f43f5e;">"browser"</span>: <span style="color: #eab308;">"Chrome"</span>,
|
||||
<span style="color: #f43f5e;">"browser_version"</span>: <span style="color: #eab308;">"120.0"</span>,
|
||||
<span style="color: #f43f5e;">"operating_system"</span>: <span style="color: #eab308;">"Android"</span>,
|
||||
<span style="color: #f43f5e;">"operating_system_version"</span>: <span style="color: #eab308;">"14"</span>,
|
||||
<span style="color: #f43f5e;">"country"</span>: <span style="color: #eab308;">"Poland"</span>,
|
||||
<span style="color: #f43f5e;">"country_code"</span>: <span style="color: #eab308;">"PL"</span>,
|
||||
<span style="color: #f43f5e;">"city"</span>: <span style="color: #eab308;">"Warsaw"</span>,
|
||||
<span style="color: #f43f5e;">"referer_url"</span>: <span style="color: #eab308;">"https://t.co/"</span>,
|
||||
<span style="color: #f43f5e;">"referer_host"</span>: <span style="color: #eab308;">"t.co"</span>,
|
||||
<span style="color: #f43f5e;">"utm_source"</span>: <span style="color: #eab308;">"twitter"</span>,
|
||||
<span style="color: #f43f5e;">"utm_medium"</span>: <span style="color: #eab308;">"social"</span>,
|
||||
<span style="color: #f43f5e;">"utm_campaign"</span>: <span style="color: #eab308;">"summer_sale"</span>,
|
||||
<span style="color: #f43f5e;">"utm_term"</span>: <span style="color: #60a5fa;">null</span>,
|
||||
<span style="color: #f43f5e;">"utm_content"</span>: <span style="color: #eab308;">"banner_ad"</span>,
|
||||
<span style="color: #f43f5e;">"is_qr_scan"</span>: <span style="color: #60a5fa;">false</span>,
|
||||
<span style="color: #f43f5e;">"browser_language"</span>: <span style="color: #eab308;">"pl"</span>
|
||||
}
|
||||
}</code></pre>
|
||||
<pre class="marketing-webhook-payload-pre"><code class="marketing-webhook-payload-code-inner">{{ $rawJson }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
default => ucfirst($device),
|
||||
};
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ addslashes($device) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: @json($device) })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
@if (strtolower($device) === 'desktop')
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByBrowser as $browser => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ addslashes($browser) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: @json($browser) })">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
@@ -124,7 +124,7 @@
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByOs as $os => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ addslashes($os) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: @json($os) })">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-cpu-chip'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
$translatedCountry = strtoupper($code);
|
||||
}
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ addslashes($code) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: @json($code) })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
@if ($code)
|
||||
@@ -76,7 +76,7 @@
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByCity as $city => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'city', value: '{{ addslashes($city) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'city', value: @json($city) })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
@@ -106,7 +106,7 @@
|
||||
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
|
||||
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLanguagesWidget::getLanguageTranslation($langCode);
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser_language', value: '{{ addslashes($langCode) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser_language', value: @json($langCode) })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
|
||||
@@ -1,13 +1,54 @@
|
||||
<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.
|
||||
Server-Sent Events push visit cursor updates to the browser.
|
||||
onStreamUpdate() advances latestVisitId and triggers a render only when needed.
|
||||
--}}
|
||||
<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">
|
||||
<div
|
||||
x-data="{
|
||||
source: null,
|
||||
cursor: @js(max($latestVisitId, 0)),
|
||||
reconnectTimer: null,
|
||||
connect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.source?.close();
|
||||
|
||||
const url = @js(route('short-url.live-feed.stream', ['shortUrl' => $record->id]))
|
||||
+ '&cursor=' + encodeURIComponent(this.cursor);
|
||||
|
||||
this.source = new EventSource(url);
|
||||
|
||||
this.source.addEventListener('update', (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
|
||||
if (payload.latest_id !== undefined) {
|
||||
this.cursor = payload.latest_id;
|
||||
$wire.onStreamUpdate(payload.latest_id);
|
||||
}
|
||||
});
|
||||
|
||||
this.source.onerror = () => {
|
||||
this.source?.close();
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
|
||||
};
|
||||
},
|
||||
init() {
|
||||
this.connect();
|
||||
},
|
||||
destroy() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
|
||||
this.source?.close();
|
||||
},
|
||||
}"
|
||||
x-init="init()"
|
||||
@disconnect.window="destroy()"
|
||||
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">
|
||||
@@ -21,7 +62,11 @@
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{{ __('filament-short-url::default.stats_live_feed_poll_interval') }}
|
||||
@if ($usesRedisPush ?? false)
|
||||
{{ __('filament-short-url::default.stats_live_feed_mode_redis') }}
|
||||
@else
|
||||
{{ __('filament-short-url::default.stats_live_feed_mode_poll') }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByReferer as $referer => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'referer_host', value: '{{ addslashes($referer) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'referer_host', value: @json($referer) })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $referer }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
@@ -94,7 +94,7 @@
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmSources as $source => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_source', value: '{{ addslashes($source) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_source', value: @json($source) })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
@@ -115,7 +115,7 @@
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmMediums as $medium => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_medium', value: '{{ addslashes($medium) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_medium', value: @json($medium) })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
@@ -136,7 +136,7 @@
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmCampaigns as $campaign => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_campaign', value: '{{ addslashes($campaign) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_campaign', value: @json($campaign) })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
@@ -157,7 +157,7 @@
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmTerms as $term => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_term', value: '{{ addslashes($term) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_term', value: @json($term) })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $term }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
@@ -178,7 +178,7 @@
|
||||
<div class="space-y-3.5 mt-2">
|
||||
@forelse ($utmContents as $content => $count)
|
||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_content', value: '{{ addslashes($content) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_content', value: @json($content) })">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $content }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'selected_variant', value: '{{ addslashes($variant) }}' })">
|
||||
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'selected_variant', value: @json($variant) })">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $variant }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlFolderApiController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLiveFeedStreamController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLogoController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlPublicStatsController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlTagApiController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlUtilityController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
||||
@@ -31,26 +38,86 @@ Route::match(
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(array_merge(['web'], config('filament-short-url.middleware', ['throttle:120,1'])));
|
||||
|
||||
Route::match(
|
||||
['GET', 'POST'],
|
||||
config('filament-short-url.route_prefix', 's').'/public-stats/{key}',
|
||||
[ShortUrlPublicStatsController::class, 'show']
|
||||
)
|
||||
->name('short-url.public-stats')
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(array_merge(['web'], ['throttle:60,1']));
|
||||
|
||||
Route::match(['GET', 'POST'], 'short-url/public-stats/{key}', [ShortUrlPublicStatsController::class, 'show'])
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(array_merge(['web'], ['throttle:60,1']));
|
||||
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([
|
||||
AuthenticateShortUrlApi::class,
|
||||
])
|
||||
->group(function () {
|
||||
Route::get('links/exists', [ShortUrlApiController::class, 'exists']);
|
||||
Route::get('links/random', [ShortUrlApiController::class, 'random']);
|
||||
Route::get('links/info', [ShortUrlApiController::class, 'info']);
|
||||
Route::put('links/upsert', [ShortUrlApiController::class, 'upsert']);
|
||||
Route::post('links/bulk', [ShortUrlApiController::class, 'bulkStore']);
|
||||
Route::post('links/bulk-delete', [ShortUrlApiController::class, 'bulkDestroy']);
|
||||
Route::patch('links/bulk-update', [ShortUrlApiController::class, 'bulkUpdate']);
|
||||
Route::get('tags', [ShortUrlTagApiController::class, 'index']);
|
||||
Route::post('tags', [ShortUrlTagApiController::class, 'store']);
|
||||
Route::match(['PUT', 'PATCH'], 'tags/{id}', [ShortUrlTagApiController::class, 'update']);
|
||||
Route::delete('tags/{id}', [ShortUrlTagApiController::class, 'destroy']);
|
||||
Route::get('folders', [ShortUrlFolderApiController::class, 'index']);
|
||||
Route::post('folders', [ShortUrlFolderApiController::class, 'store']);
|
||||
Route::match(['PUT', 'PATCH'], 'folders/{id}', [ShortUrlFolderApiController::class, 'update']);
|
||||
Route::delete('folders/{id}', [ShortUrlFolderApiController::class, 'destroy']);
|
||||
Route::get('links', [ShortUrlApiController::class, 'index']);
|
||||
Route::post('links', [ShortUrlApiController::class, 'store']);
|
||||
Route::get('links/{idOrKey}/visits/export', [ShortUrlApiController::class, 'exportVisits']);
|
||||
Route::get('links/{idOrKey}', [ShortUrlApiController::class, 'show']);
|
||||
Route::get('links/{idOrKey}/stats', [ShortUrlApiController::class, 'stats']);
|
||||
Route::get('links/{idOrKey}/visits', [ShortUrlApiController::class, 'visits']);
|
||||
Route::match(['PUT', 'PATCH'], 'links/{idOrKey}', [ShortUrlApiController::class, 'update']);
|
||||
Route::delete('links/{idOrKey}', [ShortUrlApiController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::post('admin/short-url/upload-logo', [ShortUrlLogoController::class, 'uploadLogo'])
|
||||
->name('short-url.upload-logo')
|
||||
->middleware(['web']);
|
||||
|
||||
Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLogo'])
|
||||
->name('short-url.logo');
|
||||
|
||||
Route::middleware(['web', 'auth', 'throttle:60,1'])
|
||||
->get('short-url/live-feed/{shortUrl}/stream', ShortUrlLiveFeedStreamController::class)
|
||||
->name('short-url.live-feed.stream');
|
||||
|
||||
Route::middleware(['web', 'auth', 'throttle:30,1'])
|
||||
->post('short-url/log-error', function (Request $request) {
|
||||
$validated = $request->validate([
|
||||
'message' => 'required|string|max:2000',
|
||||
'file' => 'nullable|string|max:500',
|
||||
'line' => 'nullable|integer|min:0',
|
||||
'col' => 'nullable|integer|min:0',
|
||||
'stack' => 'nullable|string|max:8000',
|
||||
]);
|
||||
|
||||
Log::error('[FilamentShortUrl JS] '.$validated['message'], [
|
||||
'file' => $validated['file'] ?? null,
|
||||
'line' => $validated['line'] ?? null,
|
||||
'col' => $validated['col'] ?? null,
|
||||
'stack' => $validated['stack'] ?? null,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
})
|
||||
->name('short-url.log-error');
|
||||
|
||||
Route::middleware(['web', 'auth'])
|
||||
->get('short-url/scrape-meta', [ShortUrlRedirectController::class, 'scrapeMeta'])
|
||||
->name('short-url.scrape-meta');
|
||||
|
||||
Route::middleware(['web', 'auth'])
|
||||
->post('short-url/check-iframeable', [ShortUrlUtilityController::class, 'checkIframeable'])
|
||||
->name('short-url.check-iframeable');
|
||||
|
||||
if (config('filament-short-url.enable_fallback_route', true)) {
|
||||
Route::fallback(ShortUrlRedirectController::class)
|
||||
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
|
||||
|
||||
92
scripts/k6/redirect-baseline.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Baseline HTTP load test for the short URL redirect hot path.
|
||||
*
|
||||
* Requires k6: https://grafana.com/docs/k6/latest/set-up/install-k6/
|
||||
*
|
||||
* Example (Herd / local app):
|
||||
* k6 run scripts/k6/redirect-baseline.js \
|
||||
* -e BASE_URL=https://wyachts-super-app.test \
|
||||
* -e URL_KEY=bench-key \
|
||||
* -e VUS=20 \
|
||||
* -e DURATION=1m
|
||||
*
|
||||
* Env:
|
||||
* BASE_URL — App origin (required), e.g. https://your-app.test
|
||||
* URL_KEY — Existing link key (required)
|
||||
* ROUTE_PREFIX — Default: s (from SHORT_URL_ROUTE_PREFIX)
|
||||
* VUS — Virtual users, default 10
|
||||
* DURATION — Scenario duration, default 30s
|
||||
* P95_MS — p95 threshold in ms, default 500
|
||||
* SLEEP_MS — Optional pause between iterations per VU
|
||||
*
|
||||
* Tip: create a dedicated link with track_visits=false for a cleaner redirect benchmark.
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
|
||||
const redirectDuration = new Trend('redirect_duration', true);
|
||||
const redirectFailures = new Rate('redirect_failures');
|
||||
|
||||
const p95ThresholdMs = Number(__ENV.P95_MS || 500);
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
redirect_baseline: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.VUS || 10),
|
||||
duration: __ENV.DURATION || '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
redirect_failures: ['rate<0.05'],
|
||||
redirect_duration: [`p(95)<${p95ThresholdMs}`],
|
||||
http_req_failed: ['rate<0.05'],
|
||||
},
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const baseUrl = (__ENV.BASE_URL || '').replace(/\/$/, '');
|
||||
const routePrefix = __ENV.ROUTE_PREFIX || 's';
|
||||
const urlKey = __ENV.URL_KEY || '';
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('BASE_URL is required (e.g. https://your-app.test)');
|
||||
}
|
||||
|
||||
if (!urlKey) {
|
||||
throw new Error('URL_KEY is required — use an existing short link key');
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/${routePrefix}/${urlKey}`;
|
||||
|
||||
const probe = http.get(url, { redirects: 0 });
|
||||
|
||||
if (![301, 302, 307, 308].includes(probe.status)) {
|
||||
throw new Error(
|
||||
`Setup probe failed: GET ${url} returned ${probe.status} (expected 301/302). ` +
|
||||
'Create the link first or disable password/max_visits for the benchmark key.',
|
||||
);
|
||||
}
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const res = http.get(data.url, {
|
||||
redirects: 0,
|
||||
tags: { name: 'short_url_redirect' },
|
||||
});
|
||||
|
||||
const ok = check(res, {
|
||||
'redirect status 301/302/307/308': (r) => [301, 302, 307, 308].includes(r.status),
|
||||
});
|
||||
|
||||
redirectDuration.add(res.timings.duration);
|
||||
redirectFailures.add(!ok);
|
||||
|
||||
if (__ENV.SLEEP_MS) {
|
||||
sleep(Number(__ENV.SLEEP_MS) / 1000);
|
||||
}
|
||||
}
|
||||
22
src/Assets/ShortUrlJs.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Assets;
|
||||
|
||||
use Filament\Support\Assets\Js;
|
||||
use Throwable;
|
||||
|
||||
class ShortUrlJs extends Js
|
||||
{
|
||||
public function getVersion(): string
|
||||
{
|
||||
try {
|
||||
$path = $this->getPublicPath();
|
||||
if (file_exists($path)) {
|
||||
return (string) filemtime($path);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
}
|
||||
|
||||
return parent::getVersion();
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
|
||||
use Bjanczak\FilamentShortUrl\Services\Stats\CrossDimensionalStatsEngine;
|
||||
use Bjanczak\FilamentShortUrl\Services\StatsSqlHelper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AggregateAndPruneVisitsCommand extends Command
|
||||
{
|
||||
@@ -27,20 +30,52 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
default => 'DATE(visited_at)',
|
||||
};
|
||||
|
||||
// 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();
|
||||
$lastAggregationDate = DB::table('short_url_settings')
|
||||
->where('key', 'last_aggregation_date')
|
||||
->value('value');
|
||||
|
||||
$dates = [];
|
||||
$current = $startCarbon->copy();
|
||||
while ($current->lte($endCarbon)) {
|
||||
$dates[] = $current->toDateString();
|
||||
$current->addDay();
|
||||
$candidateDates = DB::table('short_url_visits')
|
||||
->where('visited_at', '<', $today)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->when($lastAggregationDate, fn ($query) => $query->where('visited_at', '>', Carbon::parse($lastAggregationDate)->endOfDay()))
|
||||
->selectRaw("{$dateExpression} as aggregate_date")
|
||||
->distinct()
|
||||
->orderBy('aggregate_date')
|
||||
->pluck('aggregate_date')
|
||||
->map(function ($value): string {
|
||||
return Carbon::parse($value)->toDateString();
|
||||
})
|
||||
->all();
|
||||
|
||||
$dates = [];
|
||||
foreach ($candidateDates as $date) {
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
$start = $date.' 00:00:00';
|
||||
$end = $nextDate.' 00:00:00';
|
||||
|
||||
$rawCount = (int) DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->count();
|
||||
|
||||
$dailyCountQuery = DB::table('short_url_daily_stats');
|
||||
StatsSqlHelper::applyDailyStatsDateEquals($dailyCountQuery, $date);
|
||||
$dailyCount = (int) $dailyCountQuery->sum('visits_count');
|
||||
|
||||
$missingCrossRollupsQuery = DB::table('short_url_daily_stats');
|
||||
StatsSqlHelper::applyDailyStatsDateEquals($missingCrossRollupsQuery, $date);
|
||||
$missingCrossRollups = $missingCrossRollupsQuery
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('cross_dimensional_stats')
|
||||
->orWhereNull('cross_filter_pairs');
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($dailyCount === 0 || $rawCount !== $dailyCount || $missingCrossRollups) {
|
||||
$dates[] = $date;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,23 +84,31 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
} else {
|
||||
$this->info('Found '.count($dates).' days to aggregate.');
|
||||
|
||||
$maxAggregatedDate = null;
|
||||
$affectedShortUrlIds = [];
|
||||
|
||||
foreach ($dates as $date) {
|
||||
// Wrap date aggregation in a database transaction for data integrity
|
||||
DB::transaction(function () use ($date): void {
|
||||
DB::transaction(function () use ($date, &$affectedShortUrlIds): void {
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
$start = $date.' 00:00:00';
|
||||
$end = $nextDate.' 00:00:00';
|
||||
|
||||
// Driver-aware boolean count: MySQL/SQLite store booleans as TINYINT (= 1),
|
||||
// PostgreSQL uses a native boolean type (cast to int for aggregation).
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$qrExpr = $driver === 'pgsql'
|
||||
? 'count(case when is_qr_scan::int = 1 then 1 end) as qr_scans'
|
||||
: 'count(case when is_qr_scan = 1 then 1 end) as qr_scans';
|
||||
$botExpr = $driver === 'pgsql'
|
||||
? 'count(case when is_bot::int = 1 then 1 end) as bot_visits'
|
||||
: 'count(case when is_bot = 1 then 1 end) as bot_visits';
|
||||
$proxyExpr = $driver === 'pgsql'
|
||||
? 'count(case when is_proxy::int = 1 then 1 end) as proxy_visits'
|
||||
: 'count(case when is_proxy = 1 then 1 end) as proxy_visits';
|
||||
|
||||
$totals = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->select([
|
||||
'short_url_id',
|
||||
DB::raw('count(*) as total'),
|
||||
@@ -75,141 +118,176 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
->groupBy('short_url_id')
|
||||
->get();
|
||||
|
||||
if ($totals->isEmpty()) {
|
||||
$securityTotals = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->select([
|
||||
'short_url_id',
|
||||
DB::raw('count(*) as all_visits'),
|
||||
DB::raw($botExpr),
|
||||
DB::raw($proxyExpr),
|
||||
])
|
||||
->groupBy('short_url_id')
|
||||
->get()
|
||||
->keyBy('short_url_id');
|
||||
|
||||
if ($totals->isEmpty() && $securityTotals->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statsByUrl = [];
|
||||
|
||||
foreach ($totals as $row) {
|
||||
$statsByUrl[$row->short_url_id] = [
|
||||
$statsByUrl[$row->short_url_id] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
|
||||
'total' => (int) $row->total,
|
||||
'uniques' => (int) $row->uniques,
|
||||
'qr_scans' => (int) $row->qr_scans,
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
'language_stats' => [],
|
||||
'variant_stats' => [],
|
||||
];
|
||||
'all_visits' => 0,
|
||||
'bot_visits' => 0,
|
||||
'proxy_visits' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// Helper to fetch and populate category stats natively in database GROUP BY
|
||||
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
|
||||
$urlIds = array_keys($statsByUrl);
|
||||
if (empty($urlIds)) {
|
||||
return;
|
||||
foreach ($securityTotals as $urlId => $secRow) {
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
|
||||
'total' => 0,
|
||||
'uniques' => 0,
|
||||
'qr_scans' => 0,
|
||||
'all_visits' => (int) $secRow->all_visits,
|
||||
'bot_visits' => (int) $secRow->bot_visits,
|
||||
'proxy_visits' => (int) $secRow->proxy_visits,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->whereIn('short_url_id', $urlIds)
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '');
|
||||
$statsByUrl[$urlId]['all_visits'] = (int) $secRow->all_visits;
|
||||
$statsByUrl[$urlId]['bot_visits'] = (int) $secRow->bot_visits;
|
||||
$statsByUrl[$urlId]['proxy_visits'] = (int) $secRow->proxy_visits;
|
||||
}
|
||||
|
||||
if ($statsKey === 'city_stats') {
|
||||
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
|
||||
->groupBy(['short_url_id', 'city', 'country_code']);
|
||||
$cursor = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->select([
|
||||
'short_url_id',
|
||||
'country_code',
|
||||
'city',
|
||||
'device_type',
|
||||
'browser',
|
||||
'browser_version',
|
||||
'operating_system',
|
||||
'operating_system_version',
|
||||
'referer_host',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'browser_language',
|
||||
'selected_variant',
|
||||
'is_qr_scan',
|
||||
])
|
||||
->orderBy('id')
|
||||
->cursor();
|
||||
|
||||
foreach ($cursor as $row) {
|
||||
$urlId = $row->short_url_id;
|
||||
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
|
||||
'total' => 0,
|
||||
'uniques' => 0,
|
||||
'qr_scans' => 0,
|
||||
'all_visits' => 0,
|
||||
'bot_visits' => 0,
|
||||
'proxy_visits' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
CrossDimensionalStatsEngine::accumulateHumanVisit($row, $statsByUrl[$urlId]);
|
||||
}
|
||||
|
||||
foreach ($statsByUrl as $urlId => $bucket) {
|
||||
$exported = CrossDimensionalStatsEngine::exportForPersistence($bucket);
|
||||
$payload = [
|
||||
'visits_count' => $bucket['total'],
|
||||
'unique_visits_count' => $bucket['uniques'],
|
||||
'all_visits_count' => $bucket['all_visits'],
|
||||
'bot_visits_count' => $bucket['bot_visits'],
|
||||
'proxy_visits_count' => $bucket['proxy_visits'],
|
||||
'qr_visits_count' => $bucket['qr_scans'],
|
||||
...$exported,
|
||||
];
|
||||
|
||||
$existingDaily = ShortUrlDailyStats::query()
|
||||
->where('short_url_id', $urlId);
|
||||
|
||||
StatsSqlHelper::applyDailyStatsDateEquals($existingDaily, $date);
|
||||
$existingDaily = $existingDaily->first();
|
||||
|
||||
if ($existingDaily !== null) {
|
||||
$existingDaily->update($payload);
|
||||
} else {
|
||||
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
|
||||
->groupBy(['short_url_id', $column]);
|
||||
ShortUrlDailyStats::query()->create([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
...$payload,
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = $query->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$urlId = $row->short_url_id;
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statsKey === 'city_stats') {
|
||||
$cityVal = $row->city;
|
||||
$countryCode = $row->country_code;
|
||||
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
|
||||
} else {
|
||||
$val = $row->$column;
|
||||
}
|
||||
|
||||
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
|
||||
}
|
||||
};
|
||||
|
||||
// Populate all categories via 11 quick indexed database aggregations
|
||||
$populateStats('device_type', 'device_stats');
|
||||
$populateStats('browser', 'browser_stats');
|
||||
$populateStats('operating_system', 'os_stats');
|
||||
$populateStats('country_code', 'country_stats');
|
||||
$populateStats('city', 'city_stats');
|
||||
$populateStats('referer_host', 'referer_stats');
|
||||
$populateStats('utm_source', 'utm_source_stats');
|
||||
$populateStats('utm_medium', 'utm_medium_stats');
|
||||
$populateStats('utm_campaign', 'utm_campaign_stats');
|
||||
$populateStats('browser_language', 'language_stats');
|
||||
$populateStats('selected_variant', 'variant_stats');
|
||||
|
||||
// Write aggregated stats to ShortUrlDailyStats
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
ShortUrlDailyStats::updateOrCreate([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
], [
|
||||
'visits_count' => $s['total'],
|
||||
'unique_visits_count' => $s['uniques'],
|
||||
'device_stats' => $s['device_stats'],
|
||||
'browser_stats' => $s['browser_stats'],
|
||||
'os_stats' => $s['os_stats'],
|
||||
'country_stats' => $s['country_stats'],
|
||||
'city_stats' => $s['city_stats'],
|
||||
'referer_stats' => $s['referer_stats'],
|
||||
'utm_source_stats' => $s['utm_source_stats'],
|
||||
'utm_medium_stats' => $s['utm_medium_stats'],
|
||||
'utm_campaign_stats' => $s['utm_campaign_stats'],
|
||||
'qr_visits_count' => $s['qr_scans'],
|
||||
'language_stats' => $s['language_stats'],
|
||||
'variant_stats' => $s['variant_stats'],
|
||||
]);
|
||||
$affectedShortUrlIds[] = (int) $urlId;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Aggregated stats for {$date}.");
|
||||
$maxAggregatedDate = $date;
|
||||
}
|
||||
|
||||
if ($maxAggregatedDate !== null) {
|
||||
DB::table('short_url_settings')->updateOrInsert(
|
||||
['key' => 'last_aggregation_date'],
|
||||
[
|
||||
'value' => $maxAggregatedDate,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$affectedShortUrlIds = array_values(array_unique($affectedShortUrlIds));
|
||||
if (! empty($affectedShortUrlIds)) {
|
||||
ShortUrl::whereIn('id', $affectedShortUrlIds)
|
||||
->get()
|
||||
->each(function (ShortUrl $shortUrl): void {
|
||||
$shortUrl->clearStatsCache();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Prune old visits if enabled
|
||||
if (config('filament-short-url.pruning.enabled', true)) {
|
||||
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
|
||||
$cutoff = Carbon::now()->subDays($retentionDays)->toDateTimeString();
|
||||
|
||||
$deleted = ShortUrlVisit::where('visited_at', '<', $cutoff)->delete();
|
||||
$deleted = 0;
|
||||
do {
|
||||
$chunkDeleted = ShortUrlVisit::where('visited_at', '<', $cutoff)
|
||||
->orderBy('id')
|
||||
->limit(5000)
|
||||
->delete();
|
||||
$deleted += $chunkDeleted;
|
||||
} while ($chunkDeleted > 0);
|
||||
|
||||
$this->info("Successfully pruned {$deleted} raw visit records older than {$retentionDays} days.");
|
||||
}
|
||||
|
||||
// 3. Prune old temporary logo files (older than 24 hours)
|
||||
$disk = Storage::disk('public');
|
||||
if ($disk->exists('short-urls/tmp')) {
|
||||
$files = $disk->files('short-urls/tmp');
|
||||
$now = time();
|
||||
$prunedCount = 0;
|
||||
$prunedCount = app(ShortUrlTempStorage::class)->pruneBucketsOlderThanHours(24);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$lastModified = $disk->lastModified($file);
|
||||
if (($now - $lastModified) > 86400) { // 86400 seconds = 24 hours
|
||||
$disk->delete($file);
|
||||
$prunedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($prunedCount > 0) {
|
||||
$this->info("Successfully pruned {$prunedCount} temporary logo files older than 24 hours.");
|
||||
}
|
||||
if ($prunedCount > 0) {
|
||||
$this->info("Successfully pruned {$prunedCount} temporary files older than 24 hours.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
83
src/Console/Commands/StressRedirectCommand.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StressRedirectCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'short-url:stress-redirect
|
||||
{key : The short URL key to hit}
|
||||
{--requests=50 : Number of in-process redirect requests}
|
||||
{--warmup=0 : Warmup requests excluded from stats}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Baseline redirect hot-path timing (in-process, no external HTTP)';
|
||||
|
||||
public function handle(Kernel $kernel): int
|
||||
{
|
||||
$key = (string) $this->argument('key');
|
||||
$totalRequests = max(1, (int) $this->option('requests'));
|
||||
$warmupRequests = max(0, min($totalRequests, (int) $this->option('warmup')));
|
||||
|
||||
if (! ShortUrl::query()->where('url_key', $key)->exists()) {
|
||||
$this->error("Short URL key [{$key}] was not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$prefix = config('filament-short-url.route_prefix', 's');
|
||||
$path = '/'.$prefix.'/'.$key;
|
||||
$durations = [];
|
||||
|
||||
for ($i = 0; $i < $totalRequests; $i++) {
|
||||
$startedAt = microtime(true);
|
||||
|
||||
$request = Request::create($path, 'GET');
|
||||
$response = $kernel->handle($request);
|
||||
$kernel->terminate($request, $response);
|
||||
|
||||
$elapsedMs = (microtime(true) - $startedAt) * 1000;
|
||||
|
||||
if ($i >= $warmupRequests) {
|
||||
$durations[] = $elapsedMs;
|
||||
}
|
||||
}
|
||||
|
||||
sort($durations);
|
||||
$count = count($durations);
|
||||
$sum = array_sum($durations);
|
||||
$avg = $count > 0 ? $sum / $count : 0.0;
|
||||
$min = $count > 0 ? $durations[0] : 0.0;
|
||||
$max = $count > 0 ? $durations[$count - 1] : 0.0;
|
||||
$p95Index = $count > 0 ? (int) floor(($count - 1) * 0.95) : 0;
|
||||
$p95 = $count > 0 ? $durations[$p95Index] : 0.0;
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Key', $key],
|
||||
['Measured requests', (string) $count],
|
||||
['Warmup skipped', (string) $warmupRequests],
|
||||
['Avg (ms)', number_format($avg, 2)],
|
||||
['Min (ms)', number_format($min, 2)],
|
||||
['P95 (ms)', number_format($p95, 2)],
|
||||
['Max (ms)', number_format($max, 2)],
|
||||
]
|
||||
);
|
||||
|
||||
$this->comment('In-process baseline only. For HTTP concurrency use: k6 run scripts/k6/redirect-baseline.js -e BASE_URL=... -e URL_KEY='.$key);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Illuminate\Cache\RedisStore;
|
||||
use Bjanczak\FilamentShortUrl\Services\VisitCounterBuffer;
|
||||
use Bjanczak\FilamentShortUrl\Support\ShortUrlCacheInvalidator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
class SyncBufferedCountersCommand extends Command
|
||||
{
|
||||
@@ -16,36 +17,10 @@ class SyncBufferedCountersCommand extends Command
|
||||
/** @var string */
|
||||
protected $description = 'Sync buffered short URL visit counters from cache to the database';
|
||||
|
||||
public function handle(): int
|
||||
public function handle(VisitCounterBuffer $buffer): int
|
||||
{
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// Atomically pull the dirty-ID list so incoming increments during this command
|
||||
// are written to a fresh list rather than being lost. Strategy is driver-aware:
|
||||
// Redis uses RENAME + SMEMBERS (O(N)) for true atomicity; all other stores use
|
||||
// Cache::pull() which is atomic on most drivers (file, database, memcached).
|
||||
$store = Cache::store()->getStore();
|
||||
$isRedis = $store instanceof RedisStore;
|
||||
|
||||
if ($isRedis) {
|
||||
$tempKey = "{$dirtyKey}:temp:".time();
|
||||
try {
|
||||
$conn = $store->connection();
|
||||
if ($conn->exists($dirtyKey)) {
|
||||
$conn->rename($dirtyKey, $tempKey);
|
||||
$rawIds = $conn->smembers($tempKey);
|
||||
$conn->del($tempKey);
|
||||
$dirtyIds = $rawIds ?: [];
|
||||
} else {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} else {
|
||||
$dirtyIds = Cache::pull($dirtyKey, []);
|
||||
}
|
||||
$pull = $buffer->pullDirtyIdsForSync();
|
||||
$dirtyIds = $pull['ids'];
|
||||
|
||||
if (empty($dirtyIds)) {
|
||||
$this->info('No buffered counters to synchronize.');
|
||||
@@ -53,25 +28,14 @@ class SyncBufferedCountersCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dirtyIds = array_unique(array_filter(array_map('intval', $dirtyIds)));
|
||||
$processed = 0;
|
||||
$updatesToMake = [];
|
||||
|
||||
foreach ($dirtyIds as $id) {
|
||||
$totalKey = "{$prefix}total:{$id}";
|
||||
$uniqueKey = "{$prefix}unique:{$id}";
|
||||
$qrKey = "{$prefix}qr:{$id}";
|
||||
$deltas = $buffer->pullDeltas((int) $id);
|
||||
|
||||
$totalDelta = (int) Cache::pull($totalKey, 0);
|
||||
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
|
||||
$qrDelta = (int) Cache::pull($qrKey, 0);
|
||||
|
||||
if ($totalDelta > 0 || $uniqueDelta > 0 || $qrDelta > 0) {
|
||||
$updatesToMake[$id] = [
|
||||
'total' => $totalDelta,
|
||||
'unique' => $uniqueDelta,
|
||||
'qr' => $qrDelta,
|
||||
];
|
||||
if ($deltas['total'] > 0 || $deltas['unique'] > 0 || $deltas['qr'] > 0) {
|
||||
$updatesToMake[$id] = $deltas;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +51,6 @@ class SyncBufferedCountersCommand extends Command
|
||||
->with('customDomain')
|
||||
->get(['id', 'url_key', 'custom_domain_id']);
|
||||
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
||||
|
||||
foreach ($updatesToMake as $id => $deltas) {
|
||||
ShortUrl::where('id', $id)->update([
|
||||
'total_visits' => DB::raw("total_visits + {$deltas['total']}"),
|
||||
@@ -98,59 +60,26 @@ class SyncBufferedCountersCommand extends Command
|
||||
$processed++;
|
||||
}
|
||||
|
||||
// Bust redirect cache for all host-variant keys so subsequent requests
|
||||
// see fresh counters (for max_visits enforcement etc.)
|
||||
foreach ($shortUrls as $url) {
|
||||
$hostsToForget = array_unique(array_filter([
|
||||
'default',
|
||||
$appHost,
|
||||
$url->customDomain?->domain,
|
||||
]));
|
||||
|
||||
foreach ($hostsToForget as $host) {
|
||||
Cache::forget("filament-short-url:{$url->url_key}:{$host}");
|
||||
}
|
||||
ShortUrlCacheInvalidator::forget($url);
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// DB transaction failed — restore pulled cache values so no clicks are lost.
|
||||
// Increments are used (not set) to safely merge with any new clicks that
|
||||
// arrived during the failed transaction window.
|
||||
foreach ($updatesToMake as $id => $deltas) {
|
||||
$totalKey = "{$prefix}total:{$id}";
|
||||
$uniqueKey = "{$prefix}unique:{$id}";
|
||||
$qrKey = "{$prefix}qr:{$id}";
|
||||
|
||||
if ($deltas['total'] > 0) {
|
||||
Cache::increment($totalKey, $deltas['total']);
|
||||
}
|
||||
if ($deltas['unique'] > 0) {
|
||||
Cache::increment($uniqueKey, $deltas['unique']);
|
||||
}
|
||||
if ($deltas['qr'] > 0) {
|
||||
Cache::increment($qrKey, $deltas['qr']);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore dirty IDs using the same driver-aware strategy
|
||||
if ($isRedis) {
|
||||
$conn = $store->connection();
|
||||
$conn->sadd($dirtyKey, ...array_keys($updatesToMake));
|
||||
} else {
|
||||
$lock = Cache::lock("{$prefix}dirty_ids_lock", 2);
|
||||
$lock->get(function () use ($prefix, $updatesToMake) {
|
||||
$cachedDirty = Cache::get("{$prefix}dirty_ids", []);
|
||||
if (! is_array($cachedDirty)) {
|
||||
$cachedDirty = [];
|
||||
}
|
||||
$merged = array_unique(array_merge($cachedDirty, array_keys($updatesToMake)));
|
||||
Cache::forever("{$prefix}dirty_ids", $merged);
|
||||
});
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$buffer->restoreDeltasAfterFailedSync($updatesToMake);
|
||||
$buffer->restoreDirtyIds(
|
||||
array_keys($updatesToMake),
|
||||
$pull['connection'],
|
||||
$pull['prefixedDirtyKey'],
|
||||
$pull['requeueKey'],
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($pull['requeue'] && $pull['requeueKey'] && $pull['connection']) {
|
||||
$pull['connection']->del($pull['requeueKey']);
|
||||
}
|
||||
|
||||
$this->info("Successfully synchronized counters for {$processed} short URLs.");
|
||||
|
||||
return 0;
|
||||
|
||||
55
src/Console/Commands/VerifyCustomDomainsCommand.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class VerifyCustomDomainsCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'short-url:verify-custom-domains';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Re-verify DNS for active custom domains';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$activeDomains = ShortUrlCustomDomain::query()
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($activeDomains->isEmpty()) {
|
||||
$this->info('No active custom domains to verify.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$verified = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($activeDomains as $domain) {
|
||||
$isValid = false;
|
||||
|
||||
try {
|
||||
$isValid = $domain->verifyDns();
|
||||
} catch (\Throwable) {
|
||||
$isValid = false;
|
||||
}
|
||||
|
||||
if (! $isValid && $domain->is_verified) {
|
||||
$domain->update(['is_verified' => false]);
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
$verified++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("DNS verification finished. Verified: {$verified}, failed: {$failed}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
194
src/Filament/Forms/Components/NumberStepper.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Forms\Components;
|
||||
|
||||
use Closure;
|
||||
use Filament\Forms\Components\Contracts\CanHaveNumericState;
|
||||
use Filament\Forms\Components\Field;
|
||||
use Filament\Schemas\Components\StateCasts\Contracts\StateCast;
|
||||
use Filament\Schemas\Components\StateCasts\NumberStateCast;
|
||||
|
||||
class NumberStepper extends Field implements CanHaveNumericState
|
||||
{
|
||||
protected string $view = 'filament-short-url::forms.components.number-stepper';
|
||||
|
||||
/**
|
||||
* @var scalar | Closure | null
|
||||
*/
|
||||
protected $minValue = null;
|
||||
|
||||
/**
|
||||
* @var scalar | Closure | null
|
||||
*/
|
||||
protected $maxValue = null;
|
||||
|
||||
protected int|float|Closure $step = 1;
|
||||
|
||||
protected bool|Closure $isInteger = true;
|
||||
|
||||
protected bool|Closure $isNullable = false;
|
||||
|
||||
protected string|Closure $variant = 'primary';
|
||||
|
||||
protected string|Closure $size = 'md';
|
||||
|
||||
protected string|Closure|null $displaySuffix = null;
|
||||
|
||||
protected string|Closure|null $nullLabel = null;
|
||||
|
||||
/**
|
||||
* @param scalar | Closure | null $value
|
||||
*/
|
||||
public function minValue($value): static
|
||||
{
|
||||
$this->minValue = $value;
|
||||
|
||||
$this->rule(static function (NumberStepper $component): string {
|
||||
$value = $component->getMinValue();
|
||||
|
||||
return "min:{$value}";
|
||||
}, static fn (NumberStepper $component): bool => filled($component->getMinValue()) && ! $component->isNullable());
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param scalar | Closure | null $value
|
||||
*/
|
||||
public function maxValue($value): static
|
||||
{
|
||||
$this->maxValue = $value;
|
||||
|
||||
$this->rule(static function (NumberStepper $component): string {
|
||||
$value = $component->getMaxValue();
|
||||
|
||||
return "max:{$value}";
|
||||
}, static fn (NumberStepper $component): bool => filled($component->getMaxValue()));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function step(int|float|Closure $step): static
|
||||
{
|
||||
$this->step = $step;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function integer(bool|Closure $condition = true): static
|
||||
{
|
||||
$this->isInteger = $condition;
|
||||
|
||||
$this->rule('integer', $condition);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function nullable(bool|Closure $condition = true): static
|
||||
{
|
||||
$this->isNullable = $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function variant(string|Closure $variant): static
|
||||
{
|
||||
$this->variant = $variant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function size(string|Closure $size): static
|
||||
{
|
||||
$this->size = $size;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function suffix(string|Closure|null $suffix): static
|
||||
{
|
||||
$this->displaySuffix = $suffix;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function nullLabel(string|Closure|null $label): static
|
||||
{
|
||||
$this->nullLabel = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return scalar | null
|
||||
*/
|
||||
public function getMinValue(): mixed
|
||||
{
|
||||
return $this->evaluate($this->minValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return scalar | null
|
||||
*/
|
||||
public function getMaxValue(): mixed
|
||||
{
|
||||
return $this->evaluate($this->maxValue);
|
||||
}
|
||||
|
||||
public function getStep(): int|float
|
||||
{
|
||||
return $this->evaluate($this->step);
|
||||
}
|
||||
|
||||
public function isInteger(): bool
|
||||
{
|
||||
return (bool) $this->evaluate($this->isInteger);
|
||||
}
|
||||
|
||||
public function isNullable(): bool
|
||||
{
|
||||
return (bool) $this->evaluate($this->isNullable);
|
||||
}
|
||||
|
||||
public function getVariant(): string
|
||||
{
|
||||
return $this->evaluate($this->variant);
|
||||
}
|
||||
|
||||
public function getSize(): string
|
||||
{
|
||||
return $this->evaluate($this->size);
|
||||
}
|
||||
|
||||
public function getDisplaySuffix(): ?string
|
||||
{
|
||||
return $this->evaluate($this->displaySuffix);
|
||||
}
|
||||
|
||||
public function getNullLabel(): ?string
|
||||
{
|
||||
return $this->evaluate($this->nullLabel);
|
||||
}
|
||||
|
||||
public function isNumeric(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<StateCast>
|
||||
*/
|
||||
public function getDefaultStateCasts(): array
|
||||
{
|
||||
return [
|
||||
...parent::getDefaultStateCasts(),
|
||||
app(NumberStateCast::class, ['isNullable' => $this->isNullable()]),
|
||||
];
|
||||
}
|
||||
}
|
||||
223
src/Filament/Forms/Components/SegmentControl.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Forms\Components;
|
||||
|
||||
use Closure;
|
||||
use Filament\Forms\Components\Field;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SegmentControl extends Field
|
||||
{
|
||||
protected string $view = 'filament-short-url::forms.components.segment-control';
|
||||
|
||||
/**
|
||||
* @var array<string | int, string | array<string, mixed>> | Closure
|
||||
*/
|
||||
protected array|Closure $options = [];
|
||||
|
||||
/**
|
||||
* @var array<string | int, string> | Closure
|
||||
*/
|
||||
protected array|Closure $icons = [];
|
||||
|
||||
/**
|
||||
* @var array<string | int> | Closure
|
||||
*/
|
||||
protected array|Closure $disabledOptions = [];
|
||||
|
||||
protected string|Closure $size = 'md';
|
||||
|
||||
protected string|Closure $variant = 'default';
|
||||
|
||||
protected bool|Closure $hasSeparators = true;
|
||||
|
||||
protected bool|Closure $isFullWidth = false;
|
||||
|
||||
protected bool|Closure $isIconOnly = false;
|
||||
|
||||
protected bool|Closure $expandSelectedLabel = false;
|
||||
|
||||
/**
|
||||
* @param array<string | int, string | array<string, mixed>> | Closure $options
|
||||
*/
|
||||
public function options(array|Closure $options): static
|
||||
{
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string | int, string> | Closure $icons
|
||||
*/
|
||||
public function icons(array|Closure $icons): static
|
||||
{
|
||||
$this->icons = $icons;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string | int> | Closure $keys
|
||||
*/
|
||||
public function disabledOptions(array|Closure $keys): static
|
||||
{
|
||||
$this->disabledOptions = $keys;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function size(string|Closure $size): static
|
||||
{
|
||||
$this->size = $size;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function variant(string|Closure $variant): static
|
||||
{
|
||||
$this->variant = $variant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function separators(bool|Closure $condition = true): static
|
||||
{
|
||||
$this->hasSeparators = $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function fullWidth(bool|Closure $condition = true): static
|
||||
{
|
||||
$this->isFullWidth = $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function iconOnly(bool|Closure $condition = true): static
|
||||
{
|
||||
$this->isIconOnly = $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function expandSelectedLabel(bool|Closure $condition = true): static
|
||||
{
|
||||
$this->expandSelectedLabel = $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string|int>
|
||||
*/
|
||||
public function getOptionKeys(): array
|
||||
{
|
||||
return array_keys($this->getNormalizedOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string|int, array{label: string, icon: ?string, disabled: bool, tooltip: ?string}>
|
||||
*/
|
||||
public function getNormalizedOptions(): array
|
||||
{
|
||||
$icons = $this->getIcons();
|
||||
$disabledOptions = collect($this->getDisabledOptions())->map(fn ($key) => (string) $key);
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($this->evaluate($this->options) as $value => $option) {
|
||||
$key = is_int($value) ? $value : (string) $value;
|
||||
|
||||
if (is_string($option)) {
|
||||
$normalized[$key] = [
|
||||
'label' => $option,
|
||||
'icon' => $icons[$key] ?? $icons[$value] ?? null,
|
||||
'disabled' => $disabledOptions->contains((string) $key),
|
||||
'tooltip' => null,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($option)) {
|
||||
$normalized[$key] = [
|
||||
'label' => (string) ($option['label'] ?? $key),
|
||||
'icon' => $option['icon'] ?? $icons[$key] ?? $icons[$value] ?? null,
|
||||
'disabled' => (bool) ($option['disabled'] ?? false) || $disabledOptions->contains((string) $key),
|
||||
'tooltip' => filled($option['tooltip'] ?? null) ? (string) $option['tooltip'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string|int, string>
|
||||
*/
|
||||
public function getIcons(): array
|
||||
{
|
||||
return $this->evaluate($this->icons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string|int>
|
||||
*/
|
||||
public function getDisabledOptions(): array
|
||||
{
|
||||
return Arr::wrap($this->evaluate($this->disabledOptions));
|
||||
}
|
||||
|
||||
public function getSize(): string
|
||||
{
|
||||
return $this->evaluate($this->size);
|
||||
}
|
||||
|
||||
public function getVariant(): string
|
||||
{
|
||||
return $this->evaluate($this->variant);
|
||||
}
|
||||
|
||||
public function hasSeparators(): bool
|
||||
{
|
||||
return (bool) $this->evaluate($this->hasSeparators);
|
||||
}
|
||||
|
||||
public function isFullWidth(): bool
|
||||
{
|
||||
return (bool) $this->evaluate($this->isFullWidth);
|
||||
}
|
||||
|
||||
public function isIconOnly(): bool
|
||||
{
|
||||
return (bool) $this->evaluate($this->isIconOnly);
|
||||
}
|
||||
|
||||
public function shouldExpandSelectedLabel(): bool
|
||||
{
|
||||
return (bool) $this->evaluate($this->expandSelectedLabel);
|
||||
}
|
||||
|
||||
public function isOptionDisabled(string|int $key): bool
|
||||
{
|
||||
return $this->getNormalizedOptions()[(string) $key]['disabled'] ?? true;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->rule(function (SegmentControl $component): string {
|
||||
return Rule::in($component->getOptionKeys());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,10 @@ class ShortUrlCustomDomainResource extends Resource
|
||||
->modifyQueryUsing(fn ($query) => $query
|
||||
->withCount('shortUrls')
|
||||
->withSum('shortUrls as total_clicks', 'total_visits')
|
||||
->when(
|
||||
config('filament-short-url.user.model') && auth()->check(),
|
||||
fn ($q) => $q->where('user_id', auth()->id())
|
||||
)
|
||||
)
|
||||
->filters([])
|
||||
->actions([
|
||||
@@ -153,6 +157,19 @@ class ShortUrlCustomDomainResource extends Resource
|
||||
->label(__('filament-short-url::default.action_delete_domain'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->before(function (ShortUrlCustomDomain $record, DeleteAction $action): void {
|
||||
if (! $record->shortUrls()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot delete domain with assigned links.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->cancel();
|
||||
})
|
||||
->requiresConfirmation(fn (ShortUrlCustomDomain $record): bool => $record->shortUrls()->exists())
|
||||
->modalHeading(__('filament-short-url::default.action_delete_domain'))
|
||||
->modalDescription(__('filament-short-url::default.delete_domain_confirmation_desc'))
|
||||
->modalSubmitActionLabel(__('filament-short-url::default.action_delete_domain'))
|
||||
|
||||
@@ -25,6 +25,13 @@ class ListShortUrlCustomDomains extends ManageRecords
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false)
|
||||
->closeModalByClickingAway(false)
|
||||
->mutateFormDataUsing(function (array $data): array {
|
||||
if (auth()->check()) {
|
||||
$data['user_id'] = auth()->id();
|
||||
}
|
||||
|
||||
return $data;
|
||||
})
|
||||
->after(function ($livewire, $record) {
|
||||
$livewire->dispatch('mount-dns-setup-modal', recordId: $record->id);
|
||||
}),
|
||||
|
||||
@@ -79,16 +79,7 @@ class ShortUrlFolderResource extends Resource
|
||||
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>',
|
||||
])
|
||||
->options(ShortUrlFolder::getColorOptions())
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
@@ -58,38 +58,54 @@ class ShortUrlPixelResource extends Resource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, TextInput|Select|Toggle>
|
||||
*/
|
||||
public static function formComponents(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name'))
|
||||
->required()
|
||||
->maxLength(150)
|
||||
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
||||
|
||||
Select::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type'))
|
||||
->options(self::typeOptions())
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active'))
|
||||
->default(true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function typeOptions(): array
|
||||
{
|
||||
return [
|
||||
'meta' => 'Meta / Facebook Pixel',
|
||||
'google' => 'Google Tag (GA4 / GTM)',
|
||||
'linkedin' => 'LinkedIn Insight Tag',
|
||||
'tiktok' => 'TikTok Pixel',
|
||||
'pinterest' => 'Pinterest Tag',
|
||||
];
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name'))
|
||||
->required()
|
||||
->maxLength(150)
|
||||
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
||||
|
||||
Select::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type'))
|
||||
->options([
|
||||
'meta' => 'Meta / Facebook Pixel',
|
||||
'google' => 'Google Tag (GA4 / GTM)',
|
||||
'linkedin' => 'LinkedIn Insight Tag',
|
||||
'tiktok' => 'TikTok Pixel',
|
||||
'pinterest' => 'Pinterest Tag',
|
||||
])
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active'))
|
||||
->default(true),
|
||||
])
|
||||
->components(static::formComponents())
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortU
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables\ShortUrlsTable;
|
||||
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Support\LinkUserScope;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ShortUrlResource extends Resource
|
||||
{
|
||||
@@ -48,8 +50,6 @@ class ShortUrlResource extends Resource
|
||||
return __('filament-short-url::default.navigation_label');
|
||||
}
|
||||
|
||||
// ─── Plugin-aware navigation overrides ───────────────────────────────────
|
||||
|
||||
public static function getNavigationIcon(): string|\BackedEnum|null
|
||||
{
|
||||
try {
|
||||
@@ -90,7 +90,13 @@ class ShortUrlResource extends Resource
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resource definition ─────────────────────────────────────────────────
|
||||
/**
|
||||
* @return Builder<ShortUrl>
|
||||
*/
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return LinkUserScope::applyToQuery(parent::getEloquentQuery());
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\PasswordOpenGraphGuard;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
@@ -11,6 +12,9 @@ use Filament\Actions\CreateAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Contracts\Pagination\Paginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ListShortUrls extends ManageRecords
|
||||
@@ -26,14 +30,23 @@ class ListShortUrls extends ManageRecords
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('4xl')
|
||||
->modalWidth('5xl')
|
||||
->modalAutofocus(false)
|
||||
->closeModalByClickingAway(false)
|
||||
->stickyModalFooter()
|
||||
->stickyModalHeader()
|
||||
->createAnother(false)
|
||||
->modalCancelAction(false)
|
||||
->extraModalWindowAttributes(['class' => 'update-link-modal modal-fsl'])
|
||||
->modalFooterActions(fn ($action) => [
|
||||
$action->getModalSubmitAction(),
|
||||
])
|
||||
->mutateFormDataUsing(function (array $data, ShortUrlService $service): array {
|
||||
if (empty($data['url_key'])) {
|
||||
$data['url_key'] = $service->generateKey();
|
||||
}
|
||||
|
||||
return $data;
|
||||
return PasswordOpenGraphGuard::sanitizeSaveData($data);
|
||||
})
|
||||
->after(function (ShortUrl $record): void {
|
||||
$id = json_encode($record->id);
|
||||
@@ -133,7 +146,7 @@ class ListShortUrls extends ManageRecords
|
||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||
|
||||
$qrOptionsJson = json_encode([
|
||||
$qrOptions = [
|
||||
'type' => 'svg',
|
||||
'width' => 200,
|
||||
'height' => 200,
|
||||
@@ -151,240 +164,31 @@ class ListShortUrls extends ManageRecords
|
||||
'logoShape' => $logoShape,
|
||||
],
|
||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||
]);
|
||||
];
|
||||
|
||||
$escapedQrOptions = e($qrOptionsJson);
|
||||
|
||||
$html = <<<HTML
|
||||
<div x-data x-init="if(localStorage.getItem('fsu:hide-share-modal')==='1'){ \$nextTick(()=>\$wire.unmountAction()) }">
|
||||
|
||||
<!-- Close Button (x) -->
|
||||
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{$closeButtonText}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#6b7280;margin:0 0 6px">{$successSubtitle}</p>
|
||||
<h2 style="font-size:23px;font-weight:700;color:#111827;margin:0 0 6px;line-height:1.25">{$successTitle}</h2>
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{$successHelper}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL pill -->
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<button id="{$eid}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{$shortUrl}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{$eid}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
<button id="{$eid}_qr_btn" type="button" data-qr-options="{$escapedQrOptions}"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const container = document.getElementById('{$eid}_qr_container');
|
||||
const canvas = document.getElementById('{$eid}_qr_canvas');
|
||||
if (container.style.display === 'none') {
|
||||
container.style.display = 'block';
|
||||
const loadScript = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s2.onload = () => resolve();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
};
|
||||
loadScript().then(() => {
|
||||
if (!canvas.innerHTML) {
|
||||
const opts = JSON.parse(this.getAttribute('data-qr-options'));
|
||||
opts.data = '{$qrTargetUrl}';
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200;
|
||||
cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.moveTo(offset + radius, offset);
|
||||
cx.lineTo(offset + targetDim - radius, offset);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
cx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
cx.lineTo(offset + radius, offset + targetDim);
|
||||
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
cx.lineTo(offset, offset + radius);
|
||||
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
cx.closePath();
|
||||
}
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
cx.restore();
|
||||
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
|
||||
|
||||
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{$eid}'].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{$eid}'].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$qrCodeText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Container (toggled) -->
|
||||
<div id="{$eid}_qr_container" style="display:none;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social sharing -->
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin:18px 0 0;flex-wrap:wrap">
|
||||
<a href="mailto:?body={$encoded}" target="_blank" rel="noopener" title="Email"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#374151" 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>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={$encoded}" target="_blank" rel="noopener" title="LinkedIn"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#0077b5" 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>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={$encoded}" target="_blank" rel="noopener" title="Facebook"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#1877f2" 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>
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/tweet?url={$encoded}" target="_blank" rel="noopener" title="X (Twitter)"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:19px;height:19px;color:#0f1419" 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>
|
||||
</a>
|
||||
<a href="https://api.whatsapp.com/send?text={$encoded}" target="_blank" rel="noopener" title="WhatsApp"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#25d366" 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>
|
||||
</a>
|
||||
<a href="https://t.me/share/url?url={$encoded}" target="_blank" rel="noopener" title="Telegram"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#229ED9" fill="currentColor" viewBox="0 0 24 24"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Don't show again -->
|
||||
<div style="display:flex;justify-content:center;margin:20px 0 2px">
|
||||
<button type="button"
|
||||
x-on:click="localStorage.setItem('fsu:hide-share-modal', '1'); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="font-size:12px;color:#9ca3af;background:none;border:none;cursor:pointer;text-decoration:underline;transition:color .15s"
|
||||
onmouseover="this.style.color='#4b5563'"
|
||||
onmouseout="this.style.color='#9ca3af'">
|
||||
{$dontShowAgainText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
HTML;
|
||||
$viewData = compact(
|
||||
'shortUrl',
|
||||
'qrTargetUrl',
|
||||
'destHost',
|
||||
'urlKey',
|
||||
'eid',
|
||||
'qrOptions',
|
||||
'successTitle',
|
||||
'successSubtitle',
|
||||
'successHelper',
|
||||
'downloadSvgText',
|
||||
'downloadPngText',
|
||||
'closeButtonText',
|
||||
'copyLinkText',
|
||||
'qrCodeText',
|
||||
'openLinkText',
|
||||
'dontShowAgainText'
|
||||
);
|
||||
|
||||
return [
|
||||
Forms\Components\Placeholder::make('share_modal_content')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString($html)),
|
||||
->content(new HtmlString(view('filament-short-url::modals.share-after-create', $viewData)->render())),
|
||||
];
|
||||
}),
|
||||
];
|
||||
@@ -406,4 +210,17 @@ HTML;
|
||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', true)),
|
||||
];
|
||||
}
|
||||
|
||||
protected function paginateTableQuery(Builder $query): Paginator
|
||||
{
|
||||
$paginator = parent::paginateTableQuery($query);
|
||||
|
||||
if ($paginator instanceof AbstractPaginator) {
|
||||
ShortUrl::preloadBufferedCountersForIds(
|
||||
$paginator->getCollection()->pluck('id')->all()
|
||||
);
|
||||
}
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\NumberStepper;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
@@ -17,6 +19,7 @@ use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class AdvancedTab
|
||||
{
|
||||
@@ -57,6 +60,10 @@ class AdvancedTab
|
||||
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
Placeholder::make('rate_limiting_route_info')
|
||||
->content(fn () => new HtmlString(__('filament-short-url::default.settings_rate_limiting_route_info')))
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('rate_limiting_enabled')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
|
||||
@@ -64,22 +71,24 @@ class AdvancedTab
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
TextInput::make('rate_limiting_max_attempts')
|
||||
NumberStepper::make('rate_limiting_max_attempts')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(1000)
|
||||
->variant('secondary')
|
||||
->size('sm')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
|
||||
TextInput::make('rate_limiting_decay_seconds')
|
||||
NumberStepper::make('rate_limiting_decay_seconds')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(3600)
|
||||
->suffix('s')
|
||||
->variant('secondary')
|
||||
->size('sm')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
]),
|
||||
@@ -125,6 +134,36 @@ class AdvancedTab
|
||||
])
|
||||
->default('flag_only')
|
||||
->required()
|
||||
->live()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
Placeholder::make('vpn_block_social_warning')
|
||||
->content(fn () => new HtmlString(__('filament-short-url::default.settings_vpn_block_social_warning')))
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')
|
||||
&& $get('vpn_block_action') === 'block_with_403')
|
||||
->columnSpanFull(),
|
||||
|
||||
NumberStepper::make('vpn_detection_timeout')
|
||||
->label(__('filament-short-url::default.settings_vpn_detection_timeout'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_detection_timeout_helper'))
|
||||
->minValue(1)
|
||||
->maxValue(30)
|
||||
->suffix('s')
|
||||
->variant('tertiary')
|
||||
->size('sm')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
NumberStepper::make('vpn_detection_cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_vpn_detection_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_detection_cache_ttl_helper'))
|
||||
->minValue(60)
|
||||
->maxValue(604800)
|
||||
->step(60)
|
||||
->suffix('s')
|
||||
->variant('tertiary')
|
||||
->size('sm')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
Toggle::make('safe_browsing_enabled')
|
||||
@@ -174,6 +213,46 @@ class AdvancedTab
|
||||
)
|
||||
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_analytics_security'))
|
||||
->description(__('filament-short-url::default.settings_section_analytics_security_desc'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('click_deduplication_enabled')
|
||||
->label(__('filament-short-url::default.settings_click_dedup_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_click_dedup_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('click_deduplication_hours')
|
||||
->label(__('filament-short-url::default.settings_click_dedup_hours'))
|
||||
->helperText(__('filament-short-url::default.settings_click_dedup_hours_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(168)
|
||||
->default(1)
|
||||
->suffix('h')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('click_deduplication_enabled')),
|
||||
|
||||
Toggle::make('bot_verify_google_bot_ip')
|
||||
->label(__('filament-short-url::default.settings_bot_verify_googlebot'))
|
||||
->helperText(__('filament-short-url::default.settings_bot_verify_googlebot_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('bot_debug_secret')
|
||||
->label(__('filament-short-url::default.settings_bot_debug_secret'))
|
||||
->helperText(__('filament-short-url::default.settings_bot_debug_secret_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ class DeveloperTab
|
||||
->label(__('filament-short-url::default.api_key'))
|
||||
->disabled()
|
||||
->dehydrated()
|
||||
->copyable(copyMessage: 'Copied!', copyMessageDuration: 1500)
|
||||
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
|
||||
Select::make('scope')
|
||||
->label(__('filament-short-url::default.api_key_scope'))
|
||||
@@ -74,8 +75,12 @@ class DeveloperTab
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.active'))
|
||||
->default(true),
|
||||
TextInput::make('owner_user_id')
|
||||
->label(__('filament-short-url::default.api_key_owner_user_id'))
|
||||
->numeric()
|
||||
->nullable(),
|
||||
])
|
||||
->columns(5)
|
||||
->columns(6)
|
||||
->default([]),
|
||||
]),
|
||||
|
||||
@@ -103,11 +108,12 @@ class DeveloperTab
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
TextInput::make('webhook_signing_secret')
|
||||
->label('Webhook Signing Secret')
|
||||
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
|
||||
->label(__('filament-short-url::default.webhook_signing_secret'))
|
||||
->helperText(__('filament-short-url::default.webhook_signing_secret_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->required(fn (Get $get): bool => (bool) $get('global_webhook_enabled'))
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Services\Ga4MeasurementProtocolService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
@@ -15,7 +17,6 @@ use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class Ga4Tab
|
||||
{
|
||||
@@ -43,6 +44,12 @@ class Ga4Tab
|
||||
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
|
||||
->placeholder('1:1234567890:android:abcdef123456'),
|
||||
|
||||
TextInput::make('ga4_verify_measurement_id')
|
||||
->label(__('filament-short-url::default.settings_ga4_verify_measurement_id'))
|
||||
->helperText(__('filament-short-url::default.settings_ga4_verify_measurement_id_helper'))
|
||||
->placeholder('G-XXXXXXXXXX')
|
||||
->rule('nullable|regex:/^G-[A-Z0-9]+$/i'),
|
||||
|
||||
Actions::make([
|
||||
Action::make('verifyGa4ApiSecret')
|
||||
->label(__('filament-short-url::default.settings_ga4_verify'))
|
||||
@@ -51,7 +58,11 @@ class Ga4Tab
|
||||
->action(function (Get $get): void {
|
||||
$secret = trim($get('ga4_api_secret') ?? '');
|
||||
|
||||
if (empty($secret)) {
|
||||
if ($secret === '' || str_contains($secret, '••••')) {
|
||||
$secret = (string) app(ShortUrlSettingsManager::class)->get('ga4_api_secret', '');
|
||||
}
|
||||
|
||||
if ($secret === '') {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
||||
->warning()
|
||||
@@ -60,45 +71,43 @@ class Ga4Tab
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$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' => []],
|
||||
],
|
||||
]
|
||||
);
|
||||
$measurementId = strtoupper(trim($get('ga4_verify_measurement_id') ?? ''));
|
||||
$firebaseAppId = trim($get('ga4_firebase_app_id') ?? '');
|
||||
|
||||
$body = $response->json();
|
||||
$messages = $body['validationMessages'] ?? [];
|
||||
|
||||
$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) {
|
||||
if ($firebaseAppId === '' && $measurementId === '') {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_measurement_required'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = app(Ga4MeasurementProtocolService::class)->validateCredentials(
|
||||
$measurementId,
|
||||
$secret,
|
||||
$firebaseAppId !== '' ? $firebaseAppId : null,
|
||||
);
|
||||
|
||||
if ($result['valid']) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$firstMessage = collect($result['messages'])
|
||||
->pluck('description')
|
||||
->filter()
|
||||
->first();
|
||||
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
|
||||
->body(is_string($firstMessage) ? $firstMessage : null)
|
||||
->danger()
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -8,10 +8,16 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\SegmentControl;
|
||||
use Bjanczak\FilamentShortUrl\Services\Queue\PluginQueueWorkerTester;
|
||||
use Bjanczak\FilamentShortUrl\Services\Redis\PluginRedisConnectionTester;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
@@ -19,6 +25,72 @@ use Illuminate\Support\HtmlString;
|
||||
|
||||
class GeneralTab
|
||||
{
|
||||
private static function queueConnectionRequiresWorker(string $connection): bool
|
||||
{
|
||||
return ! in_array($connection, ['sync', 'deferred', 'background'], true);
|
||||
}
|
||||
|
||||
private static function queueModeDescription(string $connection): string
|
||||
{
|
||||
return match ($connection) {
|
||||
'redis' => __('filament-short-url::default.settings_queue_mode_desc_redis'),
|
||||
'database' => __('filament-short-url::default.settings_queue_mode_desc_database'),
|
||||
'sqs' => __('filament-short-url::default.settings_queue_mode_desc_sqs'),
|
||||
'beanstalkd' => __('filament-short-url::default.settings_queue_mode_desc_beanstalkd'),
|
||||
'deferred' => __('filament-short-url::default.settings_queue_mode_desc_deferred'),
|
||||
'background' => __('filament-short-url::default.settings_queue_mode_desc_background'),
|
||||
'failover' => __('filament-short-url::default.settings_queue_mode_desc_failover'),
|
||||
default => __('filament-short-url::default.settings_queue_mode_desc_default', ['connection' => e($connection)]),
|
||||
};
|
||||
}
|
||||
|
||||
private static function queueCalloutHtml(string $content, string $variant = 'neutral'): string
|
||||
{
|
||||
$classes = $variant === 'emerald'
|
||||
? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30'
|
||||
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10';
|
||||
|
||||
return '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border '.$classes.'" data-callout-type="info">'
|
||||
.'<div class="text-sm text-neutral-800 dark:text-neutral-300">'.$content.'</div></div>';
|
||||
}
|
||||
|
||||
private static function renderQueueConnectionCallout(Get $get): HtmlString
|
||||
{
|
||||
$connection = (string) ($get('queue_connection') ?: 'sync');
|
||||
|
||||
if ($connection === 'sync') {
|
||||
return new HtmlString('');
|
||||
}
|
||||
|
||||
$queueName = (string) ($get('queue_name') ?: 'default');
|
||||
$content = self::queueModeDescription($connection);
|
||||
|
||||
if (self::queueConnectionRequiresWorker($connection)) {
|
||||
$content .= '<br><br>'.__('filament-short-url::default.settings_queue_worker_command', [
|
||||
'connection' => e($connection),
|
||||
'queue' => e($queueName),
|
||||
]);
|
||||
}
|
||||
|
||||
return new HtmlString(self::queueCalloutHtml($content, $connection === 'redis' ? 'emerald' : 'neutral'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function queuePreviewSettings(Get $get): array
|
||||
{
|
||||
return [
|
||||
'queue_connection' => $get('queue_connection') ?: 'sync',
|
||||
'queue_name' => $get('queue_name') ?: 'default',
|
||||
'redis_host' => $get('redis_host'),
|
||||
'redis_port' => $get('redis_port'),
|
||||
'redis_password' => $get('redis_password'),
|
||||
'redis_database' => $get('redis_database'),
|
||||
'redis_key_prefix' => $get('redis_key_prefix'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the general settings form tab.
|
||||
*/
|
||||
@@ -46,13 +118,15 @@ class GeneralTab
|
||||
->alphaDash()
|
||||
->maxLength(20),
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
SegmentControl::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
])
|
||||
->size('sm')
|
||||
->separators(false)
|
||||
->required(),
|
||||
|
||||
Toggle::make('lock_url_key')
|
||||
@@ -90,7 +164,14 @@ class GeneralTab
|
||||
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false)
|
||||
->live(),
|
||||
->live()
|
||||
->disabled(fn (Get $get): bool => $get('geo_ip_driver') === 'headers')
|
||||
->dehydrated(fn (Get $get): bool => $get('geo_ip_driver') !== 'headers'),
|
||||
|
||||
Placeholder::make('trust_cdn_headers_auto_enabled')
|
||||
->content(fn () => new HtmlString(__('filament-short-url::default.settings_trust_cdn_headers_auto_enabled')))
|
||||
->visible(fn (Get $get): bool => $get('geo_ip_driver') === 'headers')
|
||||
->columnSpanFull(),
|
||||
|
||||
Placeholder::make('trust_cdn_headers_info')
|
||||
->content(function () {
|
||||
@@ -98,7 +179,8 @@ class GeneralTab
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers')
|
||||
&& $get('geo_ip_driver') !== 'headers')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
@@ -125,16 +207,111 @@ class GeneralTab
|
||||
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
|
||||
->default('default')
|
||||
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
|
||||
|
||||
Placeholder::make('queue_worker_info')
|
||||
->content(function (Get $get) {
|
||||
$queueName = $get('queue_name') ?: 'default';
|
||||
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->live(),
|
||||
|
||||
Placeholder::make('queue_mode_info')
|
||||
->hiddenLabel()
|
||||
->content(fn (Get $get): HtmlString => self::renderQueueConnectionCallout($get))
|
||||
->visible(fn (Get $get): bool => ($get('queue_connection') ?: 'sync') !== 'sync')
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_redis'))
|
||||
->description(__('filament-short-url::default.settings_section_redis_description'))
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') === 'redis')
|
||||
->schema([
|
||||
TextInput::make('redis_host')
|
||||
->label(__('filament-short-url::default.settings_redis_host'))
|
||||
->helperText(__('filament-short-url::default.settings_redis_host_helper'))
|
||||
->required(fn (Get $get): bool => $get('queue_connection') === 'redis')
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('redis_port')
|
||||
->label(__('filament-short-url::default.settings_redis_port'))
|
||||
->helperText(__('filament-short-url::default.settings_redis_port_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->required(fn (Get $get): bool => $get('queue_connection') === 'redis'),
|
||||
|
||||
TextInput::make('redis_password')
|
||||
->label(__('filament-short-url::default.settings_redis_password'))
|
||||
->helperText(__('filament-short-url::default.settings_redis_password_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••'),
|
||||
|
||||
TextInput::make('redis_database')
|
||||
->label(__('filament-short-url::default.settings_redis_database'))
|
||||
->helperText(__('filament-short-url::default.settings_redis_database_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(15)
|
||||
->required(fn (Get $get): bool => $get('queue_connection') === 'redis'),
|
||||
|
||||
TextInput::make('redis_key_prefix')
|
||||
->label(__('filament-short-url::default.settings_redis_key_prefix'))
|
||||
->helperText(__('filament-short-url::default.settings_redis_key_prefix_helper'))
|
||||
->maxLength(100)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Actions::make([
|
||||
Action::make('testRedisConnection')
|
||||
->label(__('filament-short-url::default.settings_redis_test'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') === 'redis')
|
||||
->action(function (Get $get): void {
|
||||
$result = app(PluginRedisConnectionTester::class)->test(
|
||||
queueConnectionName: 'redis',
|
||||
queueName: $get('queue_name') ?: 'default',
|
||||
previewSettings: self::queuePreviewSettings($get),
|
||||
);
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($result['ok']
|
||||
? __('filament-short-url::default.settings_redis_test_ok')
|
||||
: __('filament-short-url::default.settings_redis_test_fail'))
|
||||
->body($result['message']);
|
||||
|
||||
if ($result['ok']) {
|
||||
$notification->success();
|
||||
} else {
|
||||
$notification->danger();
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}),
|
||||
|
||||
Action::make('testQueueWorker')
|
||||
->label(__('filament-short-url::default.settings_queue_worker_test'))
|
||||
->icon('heroicon-o-play')
|
||||
->color('gray')
|
||||
->visible(fn (Get $get): bool => ($get('queue_connection') ?: 'sync') !== 'sync')
|
||||
->action(function (Get $get): void {
|
||||
$result = app(PluginQueueWorkerTester::class)->test(self::queuePreviewSettings($get));
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($result['ok']
|
||||
? __('filament-short-url::default.settings_queue_worker_test_ok')
|
||||
: __('filament-short-url::default.settings_queue_worker_test_fail'))
|
||||
->body($result['message']);
|
||||
|
||||
if ($result['ok']) {
|
||||
$notification->success();
|
||||
} else {
|
||||
$notification->warning();
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}),
|
||||
])
|
||||
->visible(fn (Get $get): bool => ($get('queue_connection') ?: 'sync') !== 'sync')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
@@ -142,7 +319,11 @@ class GeneralTab
|
||||
->schema([
|
||||
Toggle::make('counter_buffering_enabled')
|
||||
->label(__('filament-short-url::default.settings_buffering_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_buffering_helper'))
|
||||
->helperText(fn (Get $get): string => ($get('queue_connection') === 'redis')
|
||||
? __('filament-short-url::default.settings_buffering_redis_auto')
|
||||
: __('filament-short-url::default.settings_buffering_helper'))
|
||||
->disabled(fn (Get $get): bool => $get('queue_connection') === 'redis')
|
||||
->dehydrated()
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
@@ -152,7 +333,7 @@ class GeneralTab
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') === 'redis' || (bool) $get('counter_buffering_enabled'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -53,15 +53,14 @@ class GeoIpTab
|
||||
->live()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
Placeholder::make('geoip_headers_warning')
|
||||
Placeholder::make('geoip_headers_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_geoip_headers_warning');
|
||||
$html = __('filament-short-url::default.settings_geoip_headers_info');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
|
||||
$get('geo_ip_driver') === 'headers' &&
|
||||
! (bool) $get('trust_cdn_headers')
|
||||
$get('geo_ip_driver') === 'headers'
|
||||
)
|
||||
->columnSpanFull(),
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Se
|
||||
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
@@ -31,19 +30,6 @@ class QrDefaultsTab
|
||||
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('qr_size')
|
||||
->label(__('filament-short-url::default.settings_qr_size'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(100)
|
||||
->maxValue(2000)
|
||||
->required(),
|
||||
|
||||
Select::make('qr_margin')
|
||||
->label(__('filament-short-url::default.settings_qr_margin'))
|
||||
->options(array_combine(range(0, 10), range(0, 10)))
|
||||
->required(),
|
||||
|
||||
Select::make('qr_dot_style')
|
||||
->label(__('filament-short-url::default.settings_qr_dot_style'))
|
||||
->options([
|
||||
|
||||
@@ -114,6 +114,11 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'geo_ip_stats_cache_ttl' => $mgr->get('geo_ip_stats_cache_ttl', 300),
|
||||
'queue_connection' => $mgr->get('queue_connection', 'sync'),
|
||||
'queue_name' => $mgr->get('queue_name', 'default'),
|
||||
'redis_host' => $mgr->get('redis_host'),
|
||||
'redis_port' => $mgr->get('redis_port'),
|
||||
'redis_password' => filled($mgr->get('redis_password')) ? '••••••••••••••••••••' : null,
|
||||
'redis_database' => $mgr->get('redis_database'),
|
||||
'redis_key_prefix' => $mgr->get('redis_key_prefix'),
|
||||
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
|
||||
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
|
||||
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
|
||||
@@ -133,8 +138,6 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'tracking_fields_referer_url' => $mgr->get('tracking_fields_referer_url', true),
|
||||
'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true),
|
||||
'tracking_fields_browser_language' => $mgr->get('tracking_fields_browser_language', true),
|
||||
'qr_size' => $mgr->get('qr_size', 300),
|
||||
'qr_margin' => $mgr->get('qr_margin', 1),
|
||||
'qr_dot_style' => $mgr->get('qr_dot_style', 'square'),
|
||||
'qr_foreground_color' => $mgr->get('qr_foreground_color', '#000000'),
|
||||
'qr_background_color' => $mgr->get('qr_background_color', '#ffffff'),
|
||||
@@ -153,8 +156,14 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'vpn_detection_driver' => $mgr->get('vpn_detection_driver', 'ip-api'),
|
||||
'vpnapi_key' => $mgr->get('vpnapi_key'),
|
||||
'vpn_block_action' => $mgr->get('vpn_block_action', 'flag_only'),
|
||||
'vpn_detection_cache_ttl' => $mgr->get('vpn_detection_cache_ttl', 86400),
|
||||
'vpn_detection_timeout' => $mgr->get('vpn_detection_timeout', 2),
|
||||
'safe_browsing_enabled' => $mgr->get('safe_browsing_enabled', false),
|
||||
'google_safe_browsing_api_key' => $mgr->get('google_safe_browsing_api_key'),
|
||||
'click_deduplication_enabled' => $mgr->get('click_deduplication_enabled', false),
|
||||
'click_deduplication_hours' => $mgr->get('click_deduplication_hours', 1),
|
||||
'bot_verify_google_bot_ip' => $mgr->get('bot_verify_google_bot_ip', false),
|
||||
'bot_debug_secret' => $mgr->get('bot_debug_secret'),
|
||||
// Deep Linking v2.1
|
||||
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
|
||||
'aasa_json' => $aasa,
|
||||
|
||||