├── .github ├── FUNDING.yml ├── resources │ ├── demo.mp4 │ ├── preview.png │ └── pxlrbt-spotlight.png └── workflows │ └── code-style.yml ├── .gitignore ├── LICENSE.md ├── composer.json ├── mix-manifest.json ├── package-lock.json ├── package.json ├── readme.md ├── resources ├── css │ └── spotlight.css ├── dist │ ├── css │ │ └── spotlight.css │ └── js │ │ └── spotlight.js ├── js │ └── spotlight.js └── lang │ ├── de │ └── spotlight.php │ ├── en │ └── spotlight.php │ └── id │ └── spotlight.php ├── src ├── Actions │ ├── RegisterPages.php │ ├── RegisterResources.php │ └── RegisterUserMenu.php ├── Commands │ ├── PageCommand.php │ └── ResourceCommand.php ├── SpotlightPlugin.php └── SpotlightServiceProvider.php ├── tailwind.config.js └── webpack.mix.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pxlrbt 2 | -------------------------------------------------------------------------------- /.github/resources/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pxlrbt/filament-spotlight/ddf57e3bb2a9a8a1a5076875134054f9f4c3beef/.github/resources/demo.mp4 -------------------------------------------------------------------------------- /.github/resources/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pxlrbt/filament-spotlight/ddf57e3bb2a9a8a1a5076875134054f9f4c3beef/.github/resources/preview.png -------------------------------------------------------------------------------- /.github/resources/pxlrbt-spotlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pxlrbt/filament-spotlight/ddf57e3bb2a9a8a1a5076875134054f9f4c3beef/.github/resources/pxlrbt-spotlight.png -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | php-cs-fixer: 10 | name: Run Laravel Pint 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: '8.1' 20 | 21 | - name: Git checkout 22 | uses: actions/checkout@v3 23 | with: 24 | ref: ${{ github.head_ref }} 25 | 26 | - name: Install dependencies 27 | run: composer install -n --prefer-dist 28 | 29 | - name: Run Laravel Pint 30 | run: ./vendor/bin/pint 31 | 32 | - name: Commit changes 33 | uses: stefanzweifel/git-auto-commit-action@v4 34 | with: 35 | commit_message: Apply style changes 36 | file_pattern: '*.php' 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /node_modules 3 | composer.phar 4 | composer.lock 5 | .DS_Store 6 | .idea 7 | phpunit.xml 8 | .phpunit.result.cache 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2022] [Dennis Koch] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pxlrbt/filament-spotlight", 3 | "description": "Spotlight for Filament Admin", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "spotlight", 8 | "alfred", 9 | "wire-elements", 10 | "filament", 11 | "laravel-filament" 12 | ], 13 | "authors": [ 14 | { 15 | "name": "Dennis Koch", 16 | "email": "info@pixelarbeit.de" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.0", 21 | "filament/filament": "^3.0.0-stable", 22 | "wire-elements/spotlight": "^2.0" 23 | }, 24 | "require-dev": { 25 | "laravel/pint": "^1.10" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "pxlrbt\\FilamentSpotlight\\": "src/" 30 | } 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "pxlrbt\\FilamentSpotlight\\SpotlightServiceProvider" 36 | ] 37 | } 38 | }, 39 | "scripts": { 40 | "pint": "vendor/bin/pint" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/resources/dist/js/spotlight.js": "/resources/dist/js/spotlight.js", 3 | "/resources/dist/css/spotlight.css": "/resources/dist/css/spotlight.css" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "mix watch", 5 | "build": "mix --production && npm run purge", 6 | "purge": "filament-purge -i resources/dist/css/spotlight.css -o resources/dist/css/spotlight.css -v 3.x" 7 | }, 8 | "devDependencies": { 9 | "autoprefixer": "^10.1.0", 10 | "laravel-mix": "^6.0.6", 11 | "postcss": "^8.2.1", 12 | "tailwindcss": "^3.0.23", 13 | "@awcodes/filament-plugin-purge": "^1.0.1" 14 | }, 15 | "dependencies": { 16 | "fuse.js": "^6.5.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![header](./.github/resources/pxlrbt-spotlight.png) 4 |
5 | 6 | # Filament Spotlight 7 | 8 |
9 | 10 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/pxlrbt/filament-spotlight.svg?include_prereleases)](https://packagist.org/packages/pxlrbt/filament-spotlight) 11 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) 12 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/pxlrbt/filament-spotlight/code-style.yml?branch=main&label=Code%20style&style=flat-square) 13 | [![Total Downloads](https://img.shields.io/packagist/dt/pxlrbt/filament-spotlight.svg)](https://packagist.org/packages/pxlrbt/filament-spotlight) 14 | 15 |
16 | 17 | Quickly navigate your Filament Resources with Spotlight functionality. 18 | 19 | Supports pages, resources and links from the user menu. 20 | 21 |
22 | 23 | https://user-images.githubusercontent.com/22632550/159757479-ca9c3f46-7638-4889-98ba-6164e5205509.mp4 24 | 25 |
26 | 27 | 28 | ## Installation 29 | 30 | 31 | | Plugin Version | Filament Version | PHP Version | 32 | |----------------|-----------------|-------------| 33 | | 0.x | 2.x | \> 8.0 | 34 | | 1.x | 3.x | \> 8.1 | 35 | 36 | 37 | ```bash 38 | composer require pxlrbt/filament-spotlight 39 | ``` 40 | 41 | ### Assets 42 | 43 | Publish the assets (Filament > 3) 44 | 45 | ```bash 46 | php artisan filament:assets 47 | ``` 48 | 49 | ### Plugin registration 50 | 51 | To use this plugin register it in your panel configuration: 52 | 53 | ```php 54 | use pxlrbt\FilamentSpotlight\SpotlightPlugin; 55 | 56 | $panel 57 | ->plugins([ 58 | SpotlightPlugin::make(), 59 | ]); 60 | ``` 61 | 62 | ## Usage 63 | 64 | There is no configuration needed. 65 | 66 | > "its genius" 67 | 68 |   – Dan Harrin 69 | 70 | To open the Spotlight input bar you can use one of the following shortcuts: 71 | 72 | CTRL + K 73 | CMD + K 74 | CTRL + / 75 | CMD + / 76 | 77 | ### Setup 78 | 79 | This plugin relies on the same properties and methods used for Filament's global search. For records showing up with the correct name in "Edit/View" you need to set `$recordTitleAttribute`. [Check the docs for more information](https://filamentphp.com/docs/3.x/panels/resources/global-search) 80 | 81 | #### Excluding pages 82 | 83 | If you need to exclude a page from the spotlight results you may do so by adding a static `shouldRegisterSpotlight` method to the page and return false: 84 | 85 | ```php 86 | public static function shouldRegisterSpotlight(): bool 87 | { 88 | return false; 89 | } 90 | ``` 91 | 92 | This can be useful when you have pages that require URL parameters. 93 | 94 | ## Translation 95 | 96 | To translate or edit the default placeholder, you have to publish the translation file for *wire-element/spotlight*: 97 | 98 | ```php 99 | php artisan vendor:publish --tag=livewire-ui-spotlight-translations 100 | ``` 101 | 102 | 103 | 104 | ## Contributing 105 | 106 | If you want to contribute to this packages, you may want to test it in a real Filament project: 107 | 108 | - Fork this repository to your GitHub account. 109 | - Create a Filament app locally. 110 | - Clone your fork in your Filament app's root directory. 111 | - In the `/filament-spotlight` directory, create a branch for your fix, e.g. `fix/error-message`. 112 | 113 | Install the packages in your app's `composer.json`: 114 | 115 | ```json 116 | "require": { 117 | "pxlrbt/filament-spotlight": "dev-fix/error-message as main-dev", 118 | }, 119 | "repositories": [ 120 | { 121 | "type": "path", 122 | "url": "filament-spotlight" 123 | } 124 | ] 125 | ``` 126 | 127 | Now, run `composer update`. 128 | 129 | ## Credits 130 | - [Dennis Koch](https://github.com/pxlrbt) 131 | - [All Contributors](../../contributors) 132 | - [Wire Elements Spotlight](https://github.com/wire-elements/spotlight) 133 | -------------------------------------------------------------------------------- /resources/css/spotlight.css: -------------------------------------------------------------------------------- 1 | @tailwind components; 2 | @tailwind utilities; 3 | 4 | [x-cloak=""] { 5 | display: none !important; 6 | } 7 | -------------------------------------------------------------------------------- /resources/dist/css/spotlight.css: -------------------------------------------------------------------------------- 1 | .right-5{right:1.25rem}.ml-1{margin-left:.25rem}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.pt-16{padding-top:4rem}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity))}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.duration-150,.transition-opacity{transition-duration:.15s}[x-cloak=""]{display:none!important}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.focus\:border-0:focus{border-width:0}.focus\:border-transparent:focus{border-color:transparent}.focus\:shadow-none:focus{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.sm\:pt-24{padding-top:6rem}} -------------------------------------------------------------------------------- /resources/dist/js/spotlight.js: -------------------------------------------------------------------------------- 1 | (()=>{var e,t={794:(e,t,n)=>{n(27)},27:(e,t,n)=>{"use strict";function s(e){return Array.isArray?Array.isArray(e):"[object Array]"===d(e)}n.r(t);const i=1/0;function r(e){return null==e?"":function(e){if("string"==typeof e)return e;let t=e+"";return"0"==t&&1/e==-i?"-0":t}(e)}function c(e){return"string"==typeof e}function o(e){return"number"==typeof e}function h(e){return!0===e||!1===e||function(e){return a(e)&&null!==e}(e)&&"[object Boolean]"==d(e)}function a(e){return"object"==typeof e}function l(e){return null!=e}function u(e){return!e.trim().length}function d(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":Object.prototype.toString.call(e)}const p=e=>`Missing ${e} property in key`,g=e=>`Property 'weight' in key '${e}' must be a positive integer`,f=Object.prototype.hasOwnProperty;class m{constructor(e){this._keys=[],this._keyMap={};let t=0;e.forEach((e=>{let n=y(e);t+=n.weight,this._keys.push(n),this._keyMap[n.id]=n,t+=n.weight})),this._keys.forEach((e=>{e.weight/=t}))}get(e){return this._keyMap[e]}keys(){return this._keys}toJSON(){return JSON.stringify(this._keys)}}function y(e){let t=null,n=null,i=null,r=1,o=null;if(c(e)||s(e))i=e,t=M(e),n=x(e);else{if(!f.call(e,"name"))throw new Error(p("name"));const s=e.name;if(i=s,f.call(e,"weight")&&(r=e.weight,r<=0))throw new Error(g(s));t=M(s),n=x(s),o=e.getFn}return{path:t,id:n,weight:r,src:i,getFn:o}}function M(e){return s(e)?e:e.split(".")}function x(e){return s(e)?e.join("."):e}var v={isCaseSensitive:!1,includeScore:!1,keys:[],shouldSort:!0,sortFn:(e,t)=>e.score===t.score?e.idx{if(l(e))if(t[u]){const d=e[t[u]];if(!l(d))return;if(u===t.length-1&&(c(d)||o(d)||h(d)))n.push(r(d));else if(s(d)){i=!0;for(let e=0,n=d.length;e{this._keysMap[e.id]=t}))}create(){!this.isCreated&&this.docs.length&&(this.isCreated=!0,c(this.docs[0])?this.docs.forEach(((e,t)=>{this._addString(e,t)})):this.docs.forEach(((e,t)=>{this._addObject(e,t)})),this.norm.clear())}add(e){const t=this.size();c(e)?this._addString(e,t):this._addObject(e,t)}removeAt(e){this.records.splice(e,1);for(let t=e,n=this.size();t{let r=t.getFn?t.getFn(e):this.getFn(e,t.path);if(l(r))if(s(r)){let e=[];const t=[{nestedArrIndex:-1,value:r}];for(;t.length;){const{nestedArrIndex:n,value:i}=t.pop();if(l(i))if(c(i)&&!u(i)){let t={v:i,i:n,n:this.norm.get(i)};e.push(t)}else s(i)&&i.forEach(((e,n)=>{t.push({nestedArrIndex:n,value:e})}))}n.$[i]=e}else if(c(r)&&!u(r)){let e={v:r,n:this.norm.get(r)};n.$[i]=e}})),this.records.push(n)}toJSON(){return{keys:this.keys,records:this.records}}}function L(e,t,{getFn:n=v.getFn,fieldNormWeight:s=v.fieldNormWeight}={}){const i=new k({getFn:n,fieldNormWeight:s});return i.setKeys(e.map(y)),i.setSources(t),i.create(),i}function S(e,{errors:t=0,currentLocation:n=0,expectedLocation:s=0,distance:i=v.distance,ignoreLocation:r=v.ignoreLocation}={}){const c=t/e.length;if(r)return c;const o=Math.abs(s-n);return i?c+o/i:o?1:c}const I=32;function C(e,t,n,{location:s=v.location,distance:i=v.distance,threshold:r=v.threshold,findAllMatches:c=v.findAllMatches,minMatchCharLength:o=v.minMatchCharLength,includeMatches:h=v.includeMatches,ignoreLocation:a=v.ignoreLocation}={}){if(t.length>I)throw new Error(`Pattern length exceeds max of ${I}.`);const l=t.length,u=e.length,d=Math.max(0,Math.min(s,u));let p=r,g=d;const f=o>1||h,m=f?Array(u):[];let y;for(;(y=e.indexOf(t,g))>-1;){let e=S(t,{currentLocation:y,expectedLocation:d,distance:i,ignoreLocation:a});if(p=Math.min(e,p),g=y+l,f){let e=0;for(;e=h;r-=1){let c=r-1,o=n[e.charAt(c)];if(f&&(m[c]=+!!o),v[r]=(v[r+1]<<1|1)&o,s&&(v[r]|=(M[r+1]|M[r])<<1|1|M[r+1]),v[r]&k&&(x=S(t,{errors:s,currentLocation:c,expectedLocation:d,distance:i,ignoreLocation:a}),x<=p)){if(p=x,g=c,g<=d)break;h=Math.max(1,2*d-g)}}if(S(t,{errors:s+1,currentLocation:d,expectedLocation:d,distance:i,ignoreLocation:a})>p)break;M=v}const L={isMatch:g>=0,score:Math.max(.001,x)};if(f){const e=function(e=[],t=v.minMatchCharLength){let n=[],s=-1,i=-1,r=0;for(let c=e.length;r=t&&n.push([s,i]),s=-1)}return e[r-1]&&r-s>=t&&n.push([s,r-1]),n}(m,o);e.length?h&&(L.indices=e):L.isMatch=!1}return L}function _(e){let t={};for(let n=0,s=e.length;n{this.chunks.push({pattern:e,alphabet:_(e),startIndex:t})},l=this.pattern.length;if(l>I){let e=0;const t=l%I,n=l-t;for(;e{const{isMatch:g,score:f,indices:m}=C(e,t,d,{location:s+p,distance:i,threshold:r,findAllMatches:c,minMatchCharLength:o,includeMatches:n,ignoreLocation:h});g&&(u=!0),l+=f,g&&m&&(a=[...a,...m])}));let d={isMatch:u,score:u?l/this.chunks.length:1};return u&&n&&(d.indices=a),d}}class b{constructor(e){this.pattern=e}static isMultiMatch(e){return O(e,this.multiRegex)}static isSingleMatch(e){return O(e,this.singleRegex)}search(){}}function O(e,t){const n=e.match(t);return n?n[1]:null}class E extends b{constructor(e,{location:t=v.location,threshold:n=v.threshold,distance:s=v.distance,includeMatches:i=v.includeMatches,findAllMatches:r=v.findAllMatches,minMatchCharLength:c=v.minMatchCharLength,isCaseSensitive:o=v.isCaseSensitive,ignoreLocation:h=v.ignoreLocation}={}){super(e),this._bitapSearch=new $(e,{location:t,threshold:n,distance:s,includeMatches:i,findAllMatches:r,minMatchCharLength:c,isCaseSensitive:o,ignoreLocation:h})}static get type(){return"fuzzy"}static get multiRegex(){return/^"(.*)"$/}static get singleRegex(){return/^(.*)$/}search(e){return this._bitapSearch.searchIn(e)}}class A extends b{constructor(e){super(e)}static get type(){return"include"}static get multiRegex(){return/^'"(.*)"$/}static get singleRegex(){return/^'(.*)$/}search(e){let t,n=0;const s=[],i=this.pattern.length;for(;(t=e.indexOf(this.pattern,n))>-1;)n=t+i,s.push([t,n-1]);const r=!!s.length;return{isMatch:r,score:r?0:1,indices:s}}}const R=[class extends b{constructor(e){super(e)}static get type(){return"exact"}static get multiRegex(){return/^="(.*)"$/}static get singleRegex(){return/^=(.*)$/}search(e){const t=e===this.pattern;return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},A,class extends b{constructor(e){super(e)}static get type(){return"prefix-exact"}static get multiRegex(){return/^\^"(.*)"$/}static get singleRegex(){return/^\^(.*)$/}search(e){const t=e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},class extends b{constructor(e){super(e)}static get type(){return"inverse-prefix-exact"}static get multiRegex(){return/^!\^"(.*)"$/}static get singleRegex(){return/^!\^(.*)$/}search(e){const t=!e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},class extends b{constructor(e){super(e)}static get type(){return"inverse-suffix-exact"}static get multiRegex(){return/^!"(.*)"\$$/}static get singleRegex(){return/^!(.*)\$$/}search(e){const t=!e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},class extends b{constructor(e){super(e)}static get type(){return"suffix-exact"}static get multiRegex(){return/^"(.*)"\$$/}static get singleRegex(){return/^(.*)\$$/}search(e){const t=e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[e.length-this.pattern.length,e.length-1]}}},class extends b{constructor(e){super(e)}static get type(){return"inverse-exact"}static get multiRegex(){return/^!"(.*)"$/}static get singleRegex(){return/^!(.*)$/}search(e){const t=-1===e.indexOf(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},E],F=R.length,N=/ +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/;const D=new Set([E.type,A.type]);class j{constructor(e,{isCaseSensitive:t=v.isCaseSensitive,includeMatches:n=v.includeMatches,minMatchCharLength:s=v.minMatchCharLength,ignoreLocation:i=v.ignoreLocation,findAllMatches:r=v.findAllMatches,location:c=v.location,threshold:o=v.threshold,distance:h=v.distance}={}){this.query=null,this.options={isCaseSensitive:t,includeMatches:n,minMatchCharLength:s,findAllMatches:r,ignoreLocation:i,location:c,threshold:o,distance:h},this.pattern=t?e:e.toLowerCase(),this.query=function(e,t={}){return e.split("|").map((e=>{let n=e.trim().split(N).filter((e=>e&&!!e.trim())),s=[];for(let e=0,i=n.length;e!(!e[q]&&!e[T]),Q=e=>({[q]:Object.keys(e).map((t=>({[t]:e[t]})))});function V(e,t,{auto:n=!0}={}){const i=e=>{let r=Object.keys(e);const o=(e=>!!e[z])(e);if(!o&&r.length>1&&!K(e))return i(Q(e));if((e=>!s(e)&&a(e)&&!K(e))(e)){const s=o?e[z]:r[0],i=o?e[J]:e[s];if(!c(i))throw new Error((e=>`Invalid value for key ${e}`)(s));const h={keyId:x(s),pattern:i};return n&&(h.searcher=P(i,t)),h}let h={children:[],operator:r[0]};return r.forEach((t=>{const n=e[t];s(n)&&n.forEach((e=>{h.children.push(i(e))}))})),h};return K(e)||(e=Q(e)),i(e)}function U(e,t){const n=e.matches;t.matches=[],l(n)&&n.forEach((e=>{if(!l(e.indices)||!e.indices.length)return;const{indices:n,value:s}=e;let i={indices:n,value:s};e.key&&(i.key=e.key.src),e.idx>-1&&(i.refIndex=e.idx),t.matches.push(i)}))}function B(e,t){t.score=e.score}class G{constructor(e,t={},n){this.options={...v,...t},this.options.useExtendedSearch,this._keyStore=new m(this.options.keys),this.setCollection(e,n)}setCollection(e,t){if(this._docs=e,t&&!(t instanceof k))throw new Error("Incorrect 'index' type");this._myIndex=t||L(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight})}add(e){l(e)&&(this._docs.push(e),this._myIndex.add(e))}remove(e=(()=>!1)){const t=[];for(let n=0,s=this._docs.length;n{let n=1;e.matches.forEach((({key:e,norm:s,score:i})=>{const r=e?e.weight:null;n*=Math.pow(0===i&&r?Number.EPSILON:i,(r||1)*(t?1:s))})),e.score=n}))}(a,{ignoreFieldNorm:h}),i&&a.sort(r),o(t)&&t>-1&&(a=a.slice(0,t)),function(e,t,{includeMatches:n=v.includeMatches,includeScore:s=v.includeScore}={}){const i=[];return n&&i.push(U),s&&i.push(B),e.map((e=>{const{idx:n}=e,s={item:t[n],refIndex:n};return i.length&&i.forEach((t=>{t(e,s)})),s}))}(a,this._docs,{includeMatches:n,includeScore:s})}_searchStringList(e){const t=P(e,this.options),{records:n}=this._myIndex,s=[];return n.forEach((({v:e,i:n,n:i})=>{if(!l(e))return;const{isMatch:r,score:c,indices:o}=t.searchIn(e);r&&s.push({item:e,idx:n,matches:[{score:c,value:e,norm:i,indices:o}]})})),s}_searchLogical(e){const t=V(e,this.options),n=(e,t,s)=>{if(!e.children){const{keyId:n,searcher:i}=e,r=this._findMatches({key:this._keyStore.get(n),value:this._myIndex.getValueForItemAtKeyId(t,n),searcher:i});return r&&r.length?[{idx:s,item:t,matches:r}]:[]}const i=[];for(let r=0,c=e.children.length;r{if(l(e)){let c=n(t,e,s);c.length&&(i[s]||(i[s]={idx:s,item:e,matches:[]},r.push(i[s])),c.forEach((({matches:e})=>{i[s].matches.push(...e)})))}})),r}_searchObjectList(e){const t=P(e,this.options),{keys:n,records:s}=this._myIndex,i=[];return s.forEach((({$:e,i:s})=>{if(!l(e))return;let r=[];n.forEach(((n,s)=>{r.push(...this._findMatches({key:n,value:e[s],searcher:t}))})),r.length&&i.push({idx:s,item:e,matches:r})})),i}_findMatches({key:e,value:t,searcher:n}){if(!l(t))return[];let i=[];if(s(t))t.forEach((({v:t,i:s,n:r})=>{if(!l(t))return;const{isMatch:c,score:o,indices:h}=n.searchIn(t);c&&i.push({score:o,key:e,value:t,idx:s,norm:r,indices:h})}));else{const{v:s,n:r}=t,{isMatch:c,score:o,indices:h}=n.searchIn(s);c&&i.push({score:o,key:e,value:s,norm:r,indices:h})}return i}}G.version="6.6.2",G.createIndex=L,G.parseIndex=function(e,{getFn:t=v.getFn,fieldNormWeight:n=v.fieldNormWeight}={}){const{keys:s,records:i}=e,r=new k({getFn:t,fieldNormWeight:n});return r.setKeys(s),r.setIndexRecords(i),r},G.config=v,G.parseQuery=V,function(...e){W.push(...e)}(j),window.LivewireUISpotlight=function(e){return{inputPlaceholder:e.placeholder,searchEngine:"commands",commands:e.commands,commandSearch:null,selectedCommand:null,dependencySearch:null,dependencyQueryResults:window.Livewire.find(e.componentId).entangle("dependencyQueryResults"),requiredDependencies:[],currentDependency:null,resolvedDependencies:{},showResultsWithoutInput:e.showResultsWithoutInput,init:function(){var t=this;this.commandSearch=new G(this.commands,{threshold:.3,keys:["name","description","synonyms"]}),this.dependencySearch=new G([],{threshold:.3,keys:["name","description","synonyms"]}),this.$watch("dependencyQueryResults",(function(e){t.dependencySearch.setCollection(e)})),this.$watch("input",(function(e){0===e.length&&(t.selected=0),null!==t.selectedCommand&&null!==t.currentDependency&&"search"===t.currentDependency.type&&t.$wire.searchDependency(t.selectedCommand.id,t.currentDependency.id,e,t.resolvedDependencies)})),this.$watch("isOpen",(function(n){!1===n&&setTimeout((function(){t.input="",t.inputPlaceholder=e.placeholder,t.searchEngine="commands",t.resolvedDependencies={},t.selectedCommand=null,t.currentDependency=null,t.selectedCommand=null,t.requiredDependencies=[]}),300)}))},isOpen:!1,toggleOpen:function(){var e=this;this.isOpen?this.isOpen=!1:(this.input="",this.isOpen=!0,setTimeout((function(){e.$refs.input.focus()}),100))},input:"",filteredItems:function(){return"commands"===this.searchEngine?!this.input&&this.showResultsWithoutInput?this.commandSearch.getIndex().docs.map((function(e,t){return[{item:e},t]})):this.commandSearch.search(this.input).map((function(e,t){return[e,t]})):"search"===this.searchEngine?!this.input&&this.showResultsWithoutInput?this.dependencySearch.getIndex().docs.map((function(e,t){return[{item:e},t]})):this.dependencySearch.search(this.input).map((function(e,t){return[e,t]})):[]},selectUp:function(){var e=this;this.selected=Math.max(0,this.selected-1),this.$nextTick((function(){e.$refs.results.children[e.selected+1].scrollIntoView({block:"nearest"})}))},selectDown:function(){var e=this;this.selected=Math.min(this.filteredItems().length-1,this.selected+1),this.$nextTick((function(){e.$refs.results.children[e.selected+1].scrollIntoView({block:"nearest"})}))},go:function(e){var t,n=this;(null===this.selectedCommand&&(this.selectedCommand=this.commands.find((function(t){return t.id===(e||n.filteredItems()[n.selected][0].item.id)})),this.requiredDependencies=JSON.parse(JSON.stringify(this.selectedCommand.dependencies))),null!==this.currentDependency)&&(t="search"===this.currentDependency.type?e||this.filteredItems()[this.selected][0].item.id:this.input,this.resolvedDependencies[this.currentDependency.id]=t);this.requiredDependencies.length>0?(this.input="",this.currentDependency=this.requiredDependencies.pop(),this.inputPlaceholder=this.currentDependency.placeholder,this.searchEngine="search"===this.currentDependency.type&&"search"):(this.isOpen=!1,this.$wire.execute(this.selectedCommand.id,this.resolvedDependencies))},selected:0}}},578:()=>{}},n={};function s(e){var i=n[e];if(void 0!==i)return i.exports;var r=n[e]={exports:{}};return t[e](r,r.exports,s),r.exports}s.m=t,e=[],s.O=(t,n,i,r)=>{if(!n){var c=1/0;for(l=0;l=r)&&Object.keys(s.O).every((e=>s.O[e](n[h])))?n.splice(h--,1):(o=!1,r0&&e[l-1][2]>r;l--)e[l]=e[l-1];e[l]=[n,i,r]},s.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),s.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e={845:0,410:0};s.O.j=t=>0===e[t];var t=(t,n)=>{var i,r,[c,o,h]=n,a=0;if(c.some((t=>0!==e[t]))){for(i in o)s.o(o,i)&&(s.m[i]=o[i]);if(h)var l=h(s)}for(t&&t(n);as(794)));var i=s.O(void 0,[410],(()=>s(578)));i=s.O(i)})(); -------------------------------------------------------------------------------- /resources/js/spotlight.js: -------------------------------------------------------------------------------- 1 | require('/vendor/wire-elements/spotlight/resources/js/spotlight.js'); 2 | -------------------------------------------------------------------------------- /resources/lang/de/spotlight.php: -------------------------------------------------------------------------------- 1 | 'Nach :Record suchen', 5 | 'account' => 'Dein Konto', 6 | ]; 7 | -------------------------------------------------------------------------------- /resources/lang/en/spotlight.php: -------------------------------------------------------------------------------- 1 | 'Search for a :record', 5 | 'account' => 'Your account', 6 | ]; 7 | -------------------------------------------------------------------------------- /resources/lang/id/spotlight.php: -------------------------------------------------------------------------------- 1 | 'Cari :record', 5 | 'account' => 'Akun Anda', 6 | ]; 7 | -------------------------------------------------------------------------------- /src/Actions/RegisterPages.php: -------------------------------------------------------------------------------- 1 | getPages(); 15 | 16 | foreach ($pages as $pageClass) { 17 | 18 | /** 19 | * @var Page $page 20 | */ 21 | $page = new $pageClass; 22 | 23 | if (self::hasParameters($page::getSlug())) { 24 | continue; 25 | } 26 | 27 | if (method_exists($page, 'shouldRegisterSpotlight') && $page::shouldRegisterSpotlight() === false) { 28 | continue; 29 | } 30 | 31 | $name = collect([ 32 | $page->getNavigationGroup(), 33 | $page->getTitle(), 34 | ])->filter()->join(' / '); 35 | 36 | $url = $page::getUrl(); 37 | 38 | if (blank($name) || blank($url)) { 39 | continue; 40 | } 41 | 42 | $command = new PageCommand( 43 | name: $name, 44 | url: $url 45 | ); 46 | 47 | Spotlight::$commands[$command->getId()] = $command; 48 | } 49 | } 50 | 51 | private static function hasParameters(string $slug): bool 52 | { 53 | return preg_match('/{[^}]+}/', $slug) === 1; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Actions/RegisterResources.php: -------------------------------------------------------------------------------- 1 | getResources(); 15 | 16 | foreach ($resources as $resource) { 17 | if (method_exists($resource, 'shouldRegisterSpotlight') && $resource::shouldRegisterSpotlight() === false) { 18 | continue; 19 | } 20 | 21 | $pages = $resource::getPages(); 22 | 23 | foreach ($pages as $key => $page) { 24 | if (method_exists($page->getPage(), 'shouldRegisterSpotlight') && $page->getPage()::shouldRegisterSpotlight() === false) { 25 | continue; 26 | } 27 | 28 | /** 29 | * @var PageRegistration $page 30 | */ 31 | if (blank($key) || blank($page->getPage())) { 32 | continue; 33 | } 34 | 35 | $command = new ResourceCommand( 36 | resource: $resource, 37 | page: $page->getPage(), 38 | key: $key, 39 | ); 40 | 41 | Spotlight::$commands[$command->getId()] = $command; 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Actions/RegisterUserMenu.php: -------------------------------------------------------------------------------- 1 | $items 18 | */ 19 | $items = $panel->getUserMenuItems(); 20 | 21 | foreach ($items as $key => $item) { 22 | $name = $self->getName($key, $item); 23 | $url = $self->getUrl($key, $item); 24 | 25 | if (blank($name) || blank($url)) { 26 | continue; 27 | } 28 | 29 | $command = new PageCommand( 30 | name: $name, 31 | url: $url, 32 | ); 33 | 34 | Spotlight::$commands[$command->getId()] = $command; 35 | } 36 | } 37 | 38 | protected function getName(string $key, MenuItem $item): ?string 39 | { 40 | return match ($key) { 41 | 'account' => $item->getLabel() ?? __('filament-spotlight::spotlight.account'), 42 | 'logout' => $item->getLabel() ?? __('filament-panels::layout.actions.logout.label'), 43 | default => $item->getLabel() 44 | }; 45 | } 46 | 47 | protected function getUrl(string $key, MenuItem $item): ?string 48 | { 49 | return match ($key) { 50 | 'logout' => $item->getUrl() ?? Filament::getLogoutUrl(), 51 | default => $item->getUrl() 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Commands/PageCommand.php: -------------------------------------------------------------------------------- 1 | url); 21 | } 22 | 23 | public function shouldBeShown(): bool 24 | { 25 | return $this->shouldBeShown; 26 | } 27 | 28 | public function execute(Spotlight $spotlight): void 29 | { 30 | $spotlight->redirect($this->url); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Commands/ResourceCommand.php: -------------------------------------------------------------------------------- 1 | $resource 33 | * @param class-string $page 34 | */ 35 | public function __construct( 36 | string $resource, 37 | string $page, 38 | protected string $key, 39 | ) { 40 | $this->resource = new $resource; 41 | $this->page = new $page; 42 | } 43 | 44 | public function getId(): string 45 | { 46 | return md5($this->resource::class.$this->page::class); 47 | } 48 | 49 | public function getName(): string 50 | { 51 | return collect([ 52 | $this->resource::getNavigationGroup(), 53 | $this->resource::getBreadcrumb(), 54 | $this->page::getNavigationLabel(), 55 | ]) 56 | ->filter() 57 | ->join(' / '); 58 | } 59 | 60 | public function getUrl(null|int|string $recordKey): string 61 | { 62 | return $this->resource::getUrl($this->key, $recordKey ? ['record' => $recordKey] : []); 63 | } 64 | 65 | public function shouldBeShown(): bool 66 | { 67 | return match (true) { 68 | $this->page instanceof CreateRecord => $this->resource::canCreate(), 69 | default => $this->resource::canViewAny(), 70 | }; 71 | } 72 | 73 | protected function hasDependencies(): bool 74 | { 75 | return match (true) { 76 | $this->page instanceof EditRecord => true, 77 | $this->page instanceof ViewRecord => true, 78 | $this->page instanceof ManageRelatedRecords => true, 79 | default => false, 80 | }; 81 | } 82 | 83 | public function dependencies(): ?SpotlightCommandDependencies 84 | { 85 | if (! $this->hasDependencies()) { 86 | return null; 87 | } 88 | 89 | return SpotlightCommandDependencies::collection()->add( 90 | SpotlightCommandDependency::make('record')->setPlaceholder( 91 | __('filament-spotlight::spotlight.placeholder', ['record' => $this->resource::getModelLabel()]) 92 | ) 93 | ); 94 | } 95 | 96 | public function searchRecord($query): EloquentCollection|Collection|array 97 | { 98 | $resource = $this->resource; 99 | $searchQuery = $query; 100 | $query = $resource::getGlobalSearchEloquentQuery(); 101 | 102 | foreach (explode(' ', $searchQuery) as $searchQueryWord) { 103 | $query->where(function (Builder $query) use ($searchQueryWord, $resource) { 104 | $isFirst = true; 105 | 106 | foreach ($resource::getGloballySearchableAttributes() as $attributes) { 107 | static::applyGlobalSearchAttributeConstraint($query, Arr::wrap($attributes), $searchQueryWord, $isFirst, $resource); 108 | } 109 | }); 110 | } 111 | 112 | return $query 113 | ->limit(50) 114 | ->get() 115 | ->map(fn (Model $record) => new SpotlightSearchResult( 116 | $record->getRouteKey(), 117 | $resource::getGlobalSearchResultTitle($record), 118 | collect($resource::getGlobalSearchResultDetails($record)) 119 | ->map(fn ($value, $key) => $key.': '.$value) 120 | ->join(' – ') 121 | )); 122 | } 123 | 124 | protected static function applyGlobalSearchAttributeConstraint(Builder $query, array $searchAttributes, string $searchQuery, bool &$isFirst, $resource): Builder 125 | { 126 | $isForcedCaseInsensitive = $resource::isGlobalSearchForcedCaseInsensitive(); 127 | 128 | /** @var Connection $databaseConnection */ 129 | $databaseConnection = $query->getConnection(); 130 | 131 | if ($isForcedCaseInsensitive) { 132 | $searchQuery = strtolower($searchQuery); 133 | } 134 | 135 | foreach ($searchAttributes as $searchAttribute) { 136 | $whereClause = $isFirst ? 'where' : 'orWhere'; 137 | 138 | $query->when( 139 | str($searchAttribute)->contains('.'), 140 | function (Builder $query) use ($databaseConnection, $isForcedCaseInsensitive, $searchAttribute, $searchQuery, $whereClause): Builder { 141 | return $query->{"{$whereClause}Relation"}( 142 | (string) str($searchAttribute)->beforeLast('.'), 143 | generate_search_column_expression((string) str($searchAttribute)->afterLast('.'), $isForcedCaseInsensitive, $databaseConnection), 144 | 'like', 145 | "%{$searchQuery}%", 146 | ); 147 | }, 148 | fn (Builder $query) => $query->{$whereClause}( 149 | generate_search_column_expression($searchAttribute, $isForcedCaseInsensitive, $databaseConnection), 150 | 'like', 151 | "%{$searchQuery}%", 152 | ), 153 | ); 154 | 155 | $isFirst = false; 156 | } 157 | 158 | return $query; 159 | } 160 | 161 | public function execute(Spotlight $spotlight, $record = null): void 162 | { 163 | $spotlight->redirect($this->getUrl($record)); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/SpotlightPlugin.php: -------------------------------------------------------------------------------- 1 | renderHook( 32 | 'panels::scripts.after', 33 | fn () => Blade::render("@livewire('livewire-ui-spotlight')") 34 | ); 35 | } 36 | 37 | public function boot(Panel $panel): void 38 | { 39 | Filament::serving(function () use ($panel) { 40 | config()->set('livewire-ui-spotlight.include_js', false); 41 | 42 | if (Filament::hasTenancy()) { 43 | Event::listen(TenantSet::class, function () use ($panel) { 44 | self::registerNavigation($panel); 45 | }); 46 | } else { 47 | self::registerNavigation($panel); 48 | } 49 | 50 | }); 51 | 52 | } 53 | 54 | public static function registerNavigation($panel) 55 | { 56 | RegisterPages::boot($panel); 57 | RegisterResources::boot($panel); 58 | RegisterUserMenu::boot($panel); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/SpotlightServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadTranslationsFrom(__DIR__.'/../resources/lang', 'filament-spotlight'); 15 | 16 | config()->set('livewire-ui-spotlight.commands', []); 17 | 18 | FilamentAsset::register([ 19 | Css::make('spotlight-css', __DIR__.'/../resources/dist/css/spotlight.css'), 20 | Js::make('spotlight-js', __DIR__.'/../resources/dist/js/spotlight.js'), 21 | ], package: 'pxlrbt/filament-spotlight'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './vendor/wire-elements/**/*.blade.php', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix'); 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Mix Asset Management 6 | |-------------------------------------------------------------------------- 7 | | 8 | | Mix provides a clean, fluent API for defining some Webpack build steps 9 | | for your Laravel applications. By default, we are compiling the CSS 10 | | file for the application as well as bundling up all the JS files. 11 | | 12 | */ 13 | 14 | mix 15 | .js('resources/js/spotlight.js', 'resources/dist/js') 16 | .postCss('resources/css/spotlight.css', 'resources/dist/css', [ 17 | require('tailwindcss'), 18 | require('autoprefixer'), 19 | ]); 20 | --------------------------------------------------------------------------------