├── nova4 ├── postcss.config.js ├── resources │ ├── css │ │ └── card.css │ └── js │ │ ├── components │ │ ├── GroupDivider.vue │ │ ├── OrButton.vue │ │ ├── HeroIconPlus.vue │ │ ├── SelectIcon.vue │ │ ├── SlideDown.vue │ │ └── Card.vue │ │ ├── card.js │ │ └── flavors │ │ └── nova4.js ├── dist │ ├── mix-manifest.json │ └── css │ │ ├── card.css.map │ │ └── card.css ├── tailwind.config.js ├── webpack.mix.js ├── nova.mix.js └── package.json ├── art ├── refine-nova3.png └── refine-nova4.png ├── nova3 ├── dist │ ├── mix-manifest.json │ └── css │ │ └── card.css ├── resources │ ├── css │ │ └── card.css │ └── js │ │ ├── components │ │ ├── GroupDivider.vue │ │ ├── OrButton.vue │ │ ├── HeroIconPlus.vue │ │ ├── DatePicker.vue │ │ ├── SlideDown.vue │ │ └── Card.vue │ │ ├── card.js │ │ └── flavors │ │ └── nova.js ├── webpack.mix.js └── package.json ├── .prettierignore ├── routes └── api.php ├── pint.json ├── prettier.config.js ├── src ├── RefineCard.php ├── StabilizationController.php ├── CardServiceProvider.php └── RefinesModels.php ├── package.json ├── composer.json └── README.md /nova4/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /nova4/resources/css/card.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | -------------------------------------------------------------------------------- /art/refine-nova3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarondfrancis/refine-nova/HEAD/art/refine-nova3.png -------------------------------------------------------------------------------- /art/refine-nova4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarondfrancis/refine-nova/HEAD/art/refine-nova4.png -------------------------------------------------------------------------------- /nova3/dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/card.js": "/js/card.js", 3 | "/css/card.css": "/css/card.css" 4 | } 5 | -------------------------------------------------------------------------------- /nova4/dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/card.js": "/js/card.js", 3 | "/css/card.css": "/css/card.css" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | nova3/dist 2 | nova4/dist 3 | dist/ 4 | vendor/ 5 | .github/ 6 | package-lock.json 7 | composer.lock 8 | composer.json 9 | src 10 | README.md 11 | -------------------------------------------------------------------------------- /nova3/dist/css/card.css: -------------------------------------------------------------------------------- 1 | /* Shims for Tailwind 0.7.4 */ 2 | .first\:rounded-t-lg:first-child { 3 | border-top-left-radius: 0.5rem; 4 | border-top-right-radius: 0.5rem; 5 | } 6 | 7 | .max-h-60 { 8 | max-height: 15rem; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /nova3/resources/css/card.css: -------------------------------------------------------------------------------- 1 | /* Shims for Tailwind 0.7.4 */ 2 | .first\:rounded-t-lg:first-child { 3 | border-top-left-radius: 0.5rem; 4 | border-top-right-radius: 0.5rem; 5 | } 6 | 7 | .max-h-60 { 8 | max-height: 15rem; 9 | } 10 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
{{ __('Or') }}
5 |
6 |
7 | 8 | -------------------------------------------------------------------------------- /nova4/resources/js/components/GroupDivider.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "not_operator_with_successor_space": false, 5 | "cast_spaces": false, 6 | "heredoc_to_nowdoc": false, 7 | "phpdoc_summary": false, 8 | "concat_space": { 9 | "spacing": "one" 10 | }, 11 | "trailing_comma_in_multiline": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /nova3/resources/js/components/OrButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | -------------------------------------------------------------------------------- /nova4/resources/js/components/OrButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | -------------------------------------------------------------------------------- /nova4/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // We're going to use the exact Tailwind config that Nova 2 | // uses, but just change where we look for content. 3 | let novaTailwindConfig = require('../vendor/laravel/nova/tailwind.config.js') 4 | 5 | novaTailwindConfig.content = ['./resources/**/*{js,vue}'] 6 | 7 | novaTailwindConfig.important = '.refine-nova-card' 8 | 9 | module.exports = novaTailwindConfig 10 | -------------------------------------------------------------------------------- /nova3/resources/js/components/HeroIconPlus.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /nova4/resources/js/components/HeroIconPlus.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /nova3/webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix') 2 | const BundleAnalyzerPlugin = 3 | require('webpack-bundle-analyzer').BundleAnalyzerPlugin 4 | 5 | mix 6 | .setPublicPath('dist') 7 | .postCss('resources/css/card.css', 'dist/css') 8 | .js('resources/js/card.js', 'js') 9 | .vue() 10 | .webpackConfig({ 11 | externals: { 12 | vue: '{use: () => {}}', 13 | }, 14 | plugins: [ 15 | // new BundleAnalyzerPlugin() 16 | ], 17 | }) 18 | -------------------------------------------------------------------------------- /nova4/webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix') 2 | let tailwindcss = require('tailwindcss') 3 | const BundleAnalyzerPlugin = 4 | require('webpack-bundle-analyzer').BundleAnalyzerPlugin 5 | 6 | require('./nova.mix') 7 | 8 | mix 9 | .setPublicPath('dist') 10 | .js('resources/js/card.js', 'js') 11 | .vue({ version: 3 }) 12 | .postCss('resources/css/card.css', 'dist/css', [ 13 | tailwindcss('tailwind.config.js'), 14 | ]) 15 | .webpackConfig({ 16 | plugins: [ 17 | // new BundleAnalyzerPlugin() 18 | ], 19 | }) 20 | .nova('hammerstone/refine') 21 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | useTabs: false, 5 | singleQuote: true, 6 | trailingComma: 'es5', 7 | bracketSpacing: true, 8 | jsxBracketSameLine: false, 9 | semi: false, 10 | requirePragma: false, 11 | proseWrap: 'preserve', 12 | arrowParens: 'avoid', 13 | 14 | overrides: [ 15 | { 16 | files: 'resources/css/**/*.css', 17 | options: { 18 | tabWidth: 2, 19 | }, 20 | }, 21 | { 22 | files: 'resources/js/flavors/**/*.js', 23 | options: { 24 | printWidth: 120, 25 | }, 26 | }, 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /nova4/nova.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix') 2 | const path = require('path') 3 | 4 | class NovaExtension { 5 | name() { 6 | return 'nova-extension' 7 | } 8 | 9 | register(name) { 10 | this.name = name 11 | } 12 | 13 | webpackConfig(webpackConfig) { 14 | webpackConfig.externals = { 15 | vue: 'Vue', 16 | } 17 | 18 | webpackConfig.resolve.alias = { 19 | ...(webpackConfig.resolve.alias || {}), 20 | '@nova': path.join(__dirname, '../vendor/laravel/nova/resources/js'), 21 | } 22 | 23 | webpackConfig.output = { 24 | uniqueName: this.name, 25 | } 26 | } 27 | } 28 | 29 | mix.extend('nova', new NovaExtension()) 30 | -------------------------------------------------------------------------------- /nova3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "demi": "vue-demi-switch 2", 5 | "dev": "npm run demi && mix", 6 | "watch": "npm run demi && mix watch", 7 | "prod": "npm run demi && mix # @TODO Cant use --production here because it mangles something with vue-demi." 8 | }, 9 | "devDependencies": { 10 | "cross-env": "^5.0.0", 11 | "laravel-mix": "^6.0.39", 12 | "vue-loader": "^15.9.8", 13 | "vue-template-compiler": "^2.6.14", 14 | "webpack-bundle-analyzer": "^4.5.0" 15 | }, 16 | "dependencies": { 17 | "@hammerstone/refine-vue2": "^0.3.1", 18 | "@vue/composition-api": "^1.7.1", 19 | "store2": "^2.13.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /nova4/resources/js/components/SelectIcon.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /nova4/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "demi": "vue-demi-switch 3", 5 | "dev": "npm run demi && mix", 6 | "watch": "npm run demi && mix watch", 7 | "prod": "npm run demi && mix # @TODO Cant use --production here because it mangles something with vue-demi.", 8 | "nova:install": "composer update --working-dir \"../\" --with laravel/nova:\"^4.0\" && npm --prefix='../vendor/laravel/nova' ci" 9 | }, 10 | "devDependencies": { 11 | "@vue/compiler-sfc": "^3.2.22", 12 | "laravel-mix": "^6.0.41", 13 | "postcss": "^8.3.11", 14 | "tailwindcss": "^3.2.4", 15 | "vue-loader": "^16.8.3" 16 | }, 17 | "dependencies": { 18 | "@hammerstone/refine-vue3": "^0.3.1", 19 | "lodash": "^4.17.21", 20 | "store2": "^2.14.2", 21 | "webpack-bundle-analyzer": "^4.7.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /nova3/resources/js/components/DatePicker.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 37 | -------------------------------------------------------------------------------- /src/RefineCard.php: -------------------------------------------------------------------------------- 1 | withFilter($filter); 19 | } 20 | 21 | public function withFilter($filter) 22 | { 23 | if (is_string($filter)) { 24 | $filter = app($filter); 25 | } 26 | 27 | return $this->withMeta([ 28 | 'filter' => $filter, 29 | ]); 30 | } 31 | 32 | /** 33 | * Get the component name for the element. 34 | * 35 | * @return string 36 | */ 37 | public function component() 38 | { 39 | return 'refine-nova'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "refine-nova", 3 | "version": "0.0.0", 4 | "author": "Aaron Francis (http://aaronfrancis.com/)", 5 | "scripts": { 6 | "watch:nova3": "cd nova3 && npm run watch", 7 | "watch:nova4": "cd nova4 && npm run watch", 8 | "watch": "concurrently npm:watch:*", 9 | "dev:nova3": "cd nova3 && npm run dev", 10 | "dev:nova4": "cd nova4 && npm run dev", 11 | "dev": "concurrently npm:dev:*", 12 | "prod:nova3": "cd nova3 && npm run prod", 13 | "prod:nova4": "cd nova4 && npm run prod", 14 | "prod": "concurrently npm:prod:*", 15 | "prep:nova3": "cd nova3 && npm ci", 16 | "prep:nova4": "cd nova4 && npm ci && npm run nova:install", 17 | "prep": "concurrently npm:prep:*", 18 | "build": "npm run prep && npm run prod", 19 | "format": "prettier . -w" 20 | }, 21 | "devDependencies": { 22 | "concurrently": "^7.6.0", 23 | "prettier": "^2.8.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hammerstone/refine-nova", 3 | "description": "A Laravel Nova integration for the Refine query builder.", 4 | "keywords": [ 5 | "laravel", 6 | "nova" 7 | ], 8 | "license": "MIT", 9 | "require": { 10 | "php": "^7.2|^8.0", 11 | "hammerstone/refine-laravel": "^0.3.4|^0.4.0", 12 | "laravel/nova": "^2.12|^3.0|^4.0" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "Hammerstone\\Refine\\Nova\\": "src/" 17 | } 18 | }, 19 | "repositories": [ 20 | { 21 | "type": "composer", 22 | "url": "https://nova.laravel.com" 23 | }, 24 | { 25 | "type": "composer", 26 | "url": "https://refine.composer.sh" 27 | } 28 | ], 29 | "extra": { 30 | "laravel": { 31 | "providers": [ 32 | "Hammerstone\\Refine\\Nova\\CardServiceProvider" 33 | ] 34 | } 35 | }, 36 | "config": { 37 | "sort-packages": true 38 | }, 39 | "minimum-stability": "dev", 40 | "prefer-stable": true 41 | } 42 | -------------------------------------------------------------------------------- /src/StabilizationController.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace Hammerstone\Refine\Nova; 7 | 8 | use Hammerstone\Refine\Filter; 9 | use Hammerstone\Refine\Stabilizers\UrlEncodedStabilizer; 10 | use Illuminate\Http\Request; 11 | 12 | class StabilizationController 13 | { 14 | public function stabilize(Request $request) 15 | { 16 | $filter = Filter::fromState([ 17 | 'type' => $request->type, 18 | 'blueprint' => $request->blueprint, 19 | ]); 20 | 21 | $stabilizer = new UrlEncodedStabilizer(); 22 | 23 | return [ 24 | 'id' => $stabilizer->toStableId($filter), 25 | ]; 26 | } 27 | 28 | public function destabilize(Request $request) 29 | { 30 | if (!$request->id) { 31 | return [ 32 | 'blueprint' => [], 33 | ]; 34 | } 35 | 36 | $filter = (new UrlEncodedStabilizer())->fromStableId($request->id); 37 | 38 | return [ 39 | 'blueprint' => $filter->getBlueprint(), 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /nova4/dist/css/card.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"css/card.css","mappings":"AAAA,0DAAmB,CAAnB,6CAAmB,CAAnB,6CAAmB,CAAnB,8BAAmB,CAAnB,oCAAmB,CAAnB,kCAAmB,CAAnB,kCAAmB,CAAnB,2CAAmB,CAAnB,gBAAmB,CAAnB,yCAAmB,CAAnB,kBAAmB,CAAnB,2CAAmB,CAAnB,2CAAmB,CAAnB,yCAAmB,CAAnB,0CAAmB,CAAnB,uCAAmB,CAAnB,2CAAmB,CAAnB,wCAAmB,CAAnB,yCAAmB,CAAnB,yCAAmB,CAAnB,2CAAmB,CAAnB,0CAAmB,CAAnB,yCAAmB,CAAnB,0CAAmB,CAAnB,sCAAmB,CAAnB,oCAAmB,CAAnB,kDAAmB,CAAnB,sCAAmB,CAAnB,qCAAmB,CAAnB,kCAAmB,CAAnB,qCAAmB,CAAnB,4CAAmB,CAAnB,iCAAmB,CAAnB,oCAAmB,CAAnB,oCAAmB,CAAnB,mCAAmB,CAAnB,oCAAmB,CAAnB,8CAAmB,CAAnB,gDAAmB,CAAnB,uDAAmB,CAAnB,qBAAmB,CAAnB,gBAAmB,CAAnB,iDAAmB,CAAnB,2CAAmB,CAAnB,qDAAmB,CAAnB,kDAAmB,CAAnB,gEAAmB,CAAnB,sCAAmB,CAAnB,8CAAmB,CAAnB,kDAAmB,CAAnB,2CAAmB,CAAnB,sBAAmB,CAAnB,kBAAmB,CAAnB,+CAAmB,CAAnB,iDAAmB,CAAnB,+DAAmB,CAAnB,gCAAmB,CAAnB,0CAAmB,CAAnB,gDAAmB,CAAnB,mDAAmB,CAAnB,4EAAmB,CAAnB,kFAAmB,CAAnB,6CAAmB,CAAnB,sDAAmB,CAAnB,kFAAmB,CAAnB,iDAAmB,CAAnB,mCAAmB,CAAnB,oCAAmB,CAAnB,qCAAmB,CAAnB,yCAAmB,CAAnB,kBAAmB,CAAnB,6CAAmB,CAAnB,kBAAmB,CAAnB,4CAAmB,CAAnB,iBAAmB,CAAnB,yCAAmB,CAAnB,wCAAmB,CAAnB,0CAAmB,CAAnB,wCAAmB,CAAnB,0CAAmB,CAAnB,2CAAmB,CAAnB,0CAAmB,CAAnB,4CAAmB,CAAnB,8CAAmB,CAAnB,4CAAmB,CAAnB,mBAAmB,CAAnB,2CAAmB,CAAnB,gBAAmB,CAAnB,4CAAmB,CAAnB,gDAAmB,CAAnB,8CAAmB,CAAnB,iDAAmB,CAAnB,6CAAmB,CAAnB,mEAAmB,CAAnB,mEAAmB,CAAnB,mEAAmB,CAAnB,mEAAmB,CAAnB,8FAAmB,CAAnB,4FAAmB,CAAnB,yJAAmB,CAAnB,sGAAmB,CAAnB,iGAAmB,CAAnB,mFAAmB,CAAnB,0MAAmB,CAAnB,sDAAmB,CAAnB,qJAAmB,CAAnB,6IAAmB,CAAnB,qKAAmB,CAAnB,kDAAmB,CAAnB,gFCAA,6EDAA,iDCAA,8CDAA,mCCAA,6CDAA,8BCAA,wDDAA,2GCAA,mPDAA,iDCAA,+CDAA,0CCAA,+CDAA,0CCAA,2CDAA,8CCAA,6CDAA,mCCAA,6CDAA,mCCAA,0CDAA,oBCAA,2FDAA,6CCAA,uDDAA,oBCAA,uEDAA,yDCAA,0BDAA,yCCAA,4BDAA,cCAA,C","sources":["webpack://hammerstone/refine/./resources/css/card.css","webpack://hammerstone/refine/"],"sourcesContent":["@tailwind utilities;"],"names":[],"sourceRoot":""} -------------------------------------------------------------------------------- /src/CardServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->booted(function () { 23 | $this->routes(); 24 | }); 25 | 26 | if (class_exists('\Hammerstone\Refine\Conditions\Clause')) { 27 | if (is_null(Clause::$resolveComponentUsing)) { 28 | Clause::$resolveComponentUsing = Vue2Frontend::class; 29 | } 30 | } 31 | 32 | $path = Str::startsWith(Nova::version(), '4.') ? 'nova4' : 'nova3'; 33 | 34 | Nova::serving(function (ServingNova $event) use ($path) { 35 | Nova::script('refine-nova-card', __DIR__ . "/../$path/dist/js/card.js"); 36 | Nova::style('refine-nova', __DIR__ . "/../$path/dist/css/card.css"); 37 | }); 38 | } 39 | 40 | /** 41 | * Register the card's routes. 42 | * 43 | * @return void 44 | */ 45 | protected function routes() 46 | { 47 | if ($this->app->routesAreCached()) { 48 | return; 49 | } 50 | 51 | Route::middleware(['nova']) 52 | ->prefix('nova-vendor/refine-nova') 53 | ->group(__DIR__ . '/../routes/api.php'); 54 | } 55 | 56 | /** 57 | * Register any application services. 58 | * 59 | * @return void 60 | */ 61 | public function register() 62 | { 63 | // 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /nova3/resources/js/card.js: -------------------------------------------------------------------------------- 1 | import VueCompositionApi from '@vue/composition-api' 2 | import Card from './components/Card' 3 | import { RefinePlugin } from '@hammerstone/refine-vue2' 4 | 5 | // Custom components for Nova 3. 6 | import NovaDatePicker from './components/DatePicker' 7 | import OrButton from './components/OrButton' 8 | import GroupDivider from './components/GroupDivider' 9 | 10 | Nova.booting((Vue, router) => { 11 | // Turn on for to get the Devtools to show up. 12 | // Vue.config.devtools = true; 13 | // __VUE_DEVTOOLS_GLOBAL_HOOK__.Vue = Vue 14 | 15 | // Required for Refine Vue2 to work. 16 | Vue.use(VueCompositionApi) 17 | 18 | Vue.use(RefinePlugin) 19 | 20 | // Custom components for our Nova flavor. 21 | Vue.component('refine-custom-or-button', OrButton) 22 | Vue.component('refine-custom-group-divider', GroupDivider) 23 | Vue.component('refine-custom-date-picker', NovaDatePicker) 24 | 25 | // Main card. 26 | Vue.component('refine-nova', Card) 27 | 28 | attachInterceptors(router) 29 | }) 30 | 31 | function attachInterceptors(router) { 32 | // Add a request interceptor so that we can add our Refine query params. 33 | Nova.request().interceptors.request.use(function (config) { 34 | // Instead of checking route patterns, just piggyback onto 35 | // any request where the filters are included, because 36 | // we'll want to Refine all of those requests. 37 | if (_.has(config, 'params.filters')) { 38 | for (let param in router.currentRoute.query) { 39 | // Add every query param that ends in _refine, because 40 | // each resource will start with something different, 41 | // but they all end in _refine. 42 | if (_.endsWith(param, '_refine')) { 43 | config.params[param] = router.currentRoute.query[param] 44 | } 45 | } 46 | } 47 | 48 | return config 49 | }) 50 | 51 | // Add a response interceptor so we can catch validation errors. 52 | Nova.request().interceptors.response.use( 53 | function (response) { 54 | return response 55 | }, 56 | function (error) { 57 | if (error.response && error.response.status === 422) { 58 | // Emit an event with the error data over to our Card 59 | // component and then let the rejection fall through. 60 | Nova.$emit('validation-error', error.response) 61 | } 62 | 63 | return Promise.reject(error) 64 | } 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/RefinesModels.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace Hammerstone\Refine\Nova; 7 | 8 | use Hammerstone\Refine\Blueprints\Blueprint; 9 | use Hammerstone\Refine\Filter; 10 | use Hammerstone\Refine\Stabilizers\UrlEncodedStabilizer; 11 | use Laravel\Nova\Http\Requests\NovaRequest; 12 | 13 | trait RefinesModels 14 | { 15 | /** 16 | * @return string|Filter 17 | */ 18 | public static function refineFilter(NovaRequest $request = null) 19 | { 20 | $filter = static::$filter; 21 | 22 | if (is_string($filter)) { 23 | $filter = app($filter, [ 24 | 'blueprint' => static::getBlueprint($request) 25 | ]); 26 | } 27 | 28 | return $filter; 29 | } 30 | 31 | /** 32 | * @return RefineCard 33 | */ 34 | public static function refineCard(NovaRequest $request = null) 35 | { 36 | return RefineCard::forFilter(static::refineFilter($request)); 37 | } 38 | 39 | /** 40 | * @return mixed 41 | */ 42 | public static function indexQuery(NovaRequest $request, $query) 43 | { 44 | return static::refine($request, $query); 45 | } 46 | 47 | /** 48 | * @return array|Blueprint 49 | */ 50 | public static function defaultBlueprint() 51 | { 52 | return []; 53 | } 54 | 55 | /** 56 | * @return mixed 57 | */ 58 | public static function refine(NovaRequest $request, $query) 59 | { 60 | $filter = static::refineFilter($request); 61 | 62 | if (is_string($filter)) { 63 | $filter = app($filter, [ 64 | 'blueprint' => static::getBlueprint($request), 65 | ]); 66 | } 67 | 68 | // Typically we would start with the `initialQuery` from the filter, 69 | // but we can't do that in a Nova context, so we give Refine 70 | // the query as-is and let it bind in the user's intent. 71 | $filter->useInitialQuery($query)->bind(); 72 | 73 | return $query; 74 | } 75 | 76 | /** 77 | * @return array 78 | */ 79 | protected static function getBlueprint(NovaRequest $request = null) 80 | { 81 | // The NovaRequest isn't passed to the `cards` method so it might be null. 82 | // We just need the query params so we use the global request helper. 83 | if ($id = request()->input(static::uriKey() . '_refine')) { 84 | return (new UrlEncodedStabilizer)->fromStableId($id)->getBlueprint(); 85 | } 86 | 87 | return static::defaultBlueprint(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /nova3/resources/js/components/SlideDown.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 101 | -------------------------------------------------------------------------------- /nova4/resources/js/components/SlideDown.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 101 | -------------------------------------------------------------------------------- /nova4/resources/js/card.js: -------------------------------------------------------------------------------- 1 | import Card from './components/Card' 2 | import endsWith from 'lodash/endsWith' 3 | import { RefinePlugin } from '@hammerstone/refine-vue3' 4 | 5 | import SelectIcon from './components/SelectIcon' 6 | import OrButton from './components/OrButton' 7 | import GroupDivider from './components/GroupDivider' 8 | 9 | Nova.booting((Vue, store) => { 10 | Vue.component('custom-select-icon', SelectIcon) 11 | Vue.component('custom-or-button', OrButton) 12 | Vue.component('custom-group-divider', GroupDivider) 13 | Vue.use(RefinePlugin, { 14 | showLocators: true, 15 | }) 16 | 17 | // Turn on for to get the Devtools to show up. 18 | // Vue.config.devtools = true; 19 | // __VUE_DEVTOOLS_GLOBAL_HOOK__.Vue = Vue 20 | 21 | Vue.component('refine-nova', Card) 22 | }) 23 | 24 | // We have to wrap the Nova.request method to be able to attach our 25 | // axios interceptors. In Nova 3, axios was a singleton, but in 26 | // Nova 4 axios gets created on every call of Nova.request, so 27 | // we can't attach the interceptors once to the singleton. 28 | const originalNovaRequest = Nova.request 29 | Nova.request = options => { 30 | // Call the original without any options 31 | // to get back the axios instance. 32 | let axios = originalNovaRequest.call(Nova) 33 | 34 | attachInterceptors(axios) 35 | 36 | // Mimic what's in the original function. 37 | if (options) { 38 | return axios(options) 39 | } 40 | 41 | return axios 42 | } 43 | 44 | function attachInterceptors(axios) { 45 | // Add a request interceptor so that we can add our Refine query params. 46 | axios.interceptors.request.use(function (config) { 47 | let shouldAttach = 48 | // Piggyback onto any request where the filters are included, 49 | // because we'll want to Refine all of those requests. 50 | config?.params?.hasOwnProperty('filters') || 51 | // Also attach to the cards endpoint, so that we can 52 | // get the right blueprint on initial load. 53 | config?.url?.endsWith('/cards') 54 | 55 | if (shouldAttach) { 56 | // Add every query param that ends in _refine, because 57 | // each resource will start with something different, 58 | // but they all end in _refine. 59 | new URLSearchParams(window.location.search).forEach((value, key) => { 60 | if (endsWith(key, '_refine')) { 61 | if (!config.params) { 62 | config.params = {} 63 | } 64 | config.params[key] = value 65 | } 66 | }) 67 | } 68 | 69 | return config 70 | }) 71 | 72 | // Add a response interceptor so we can catch validation errors. 73 | axios.interceptors.response.use( 74 | response => response, 75 | error => { 76 | if (error.response && error.response.status === 422) { 77 | // Emit an event with the error data over to our Card 78 | // component and then let the rejection fall through. 79 | Nova.$emit('validation-error', error.response) 80 | } 81 | 82 | return Promise.reject(error) 83 | } 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Refine for Laravel Nova 2 | 3 | [Refine](https://hammerstone.dev) is a powerful, visual query builder for Laravel Nova 3 and 4. Refine is a paid 4 | package, which you can purchase at [hammerstone.dev](https://hammerstone.dev). 5 | 6 | > We also have libraries for Laravel (without Nova) and Ruby on Rails at [hammerstone.dev](https://hammerstone.dev). 7 | 8 | Refine lets you define filterable conditions per resource, and then your users can mix and match them in any way they 9 | want to find exactly what they're looking for. 10 | 11 | ```php 12 | // Create a filter called "UserFilter" 13 | class UserFilter extends Filter 14 | { 15 | public function conditions() 16 | { 17 | return [ 18 | // Number condition on the ID column 19 | NumericCondition::make('id', 'ID'), 20 | 21 | // Text condition on the name column 22 | TextCondition::make('name', 'Name'), 23 | 24 | // Boolean condition on the is_subscriber column 25 | BooleanCondition::make('is_subscriber', 'Subscriber'), 26 | 27 | // Option condition on the referral column 28 | OptionCondition::make('referral', 'Referral Source') 29 | ->options([ 30 | 'twitter' => 'Twitter', 31 | 'linkedin' => 'LinkedIn', 32 | 'fb' => 'Facebook' 33 | ]), 34 | 35 | // Date condition on the created_at column 36 | DateWithTimeCondition::make('created_at', 'Created At'), 37 | ]; 38 | } 39 | } 40 | ``` 41 | 42 | ![Refine Nova 4](art/refine-nova4.png) 43 | Refine for Laravel Nova 4 44 | 45 | ![Refine Nova 3](art/refine-nova3.png) 46 | Refine for Laravel Nova 3 47 | 48 | ## Installation 49 | 50 | To use Refine with Nova, you must first require the package `composer require hammerstone/refine-nova`. This will 51 | install `hammerstone/refine-laravel` as well. Since `refine-laravel` is a paid package, you will need to make sure your 52 | credentials are available to composer in the `auth.json` file. 53 | 54 | ## Integration 55 | 56 | Create a `UserFilter` class in your `app\Filters` directory. 57 | 58 | ```php 59 | namespace App\Filters; 60 | 61 | use Hammerstone\Refine\Conditions\NumericCondition; 62 | use Hammerstone\Refine\Filter; 63 | 64 | class UserFilter extends Filter 65 | { 66 | public function conditions() 67 | { 68 | return [ 69 | NumericCondition::make('id', 'ID'), 70 | 71 | // @TODO: Add more conditions 72 | ]; 73 | } 74 | } 75 | ``` 76 | 77 | In your `app\Nova\User` file, you'll need to add the `RefinesModels` trait and reference your newly created filter. 78 | 79 | ```php 80 | use App\Filters\UserFilter; 81 | use Hammerstone\Refine\Nova\RefinesModels; 82 | 83 | class User extends Resource 84 | { 85 | use RefinesModels; 86 | 87 | // ... 88 | 89 | public static $filter = UserFilter::class; 90 | } 91 | ``` 92 | 93 | Finally, to show the query builder on the frontend, you'll need to add the Refine card: 94 | 95 | ```php 96 | public function cards(Request $request) 97 | { 98 | return [ 99 | static::refineCard() 100 | ]; 101 | } 102 | ``` 103 | 104 | That's it! You should see the Refine query builder on the Users page in Nova. You can read further documentation about 105 | building filters in the [Refine documentation](https://hammerstone.dev/refine/laravel/docs/main). -------------------------------------------------------------------------------- /nova3/resources/js/flavors/nova.js: -------------------------------------------------------------------------------- 1 | const novaFlavor = { 2 | emptyGroup: { 3 | class: 4 | 'border rounded-lg shadow border-50 p-2 text-80 bg-white flex items-center justify-between text-sm mb-4', 5 | 6 | wrapper: 7 | 'border rounded-lg shadow border-50 p-2 text-80 bg-white flex items-center justify-between text-sm mb-4', 8 | 9 | addCriterionButton: { 10 | class: 'text-sm flex items-center p-2', 11 | wrapper: {}, 12 | icon: 'text-80 h-4 w-4', 13 | text: 'pt-px text-80', 14 | }, 15 | }, 16 | 17 | group: { 18 | class: 'border rounded-lg shadow border-50', 19 | wrapper: '', 20 | 21 | divider: { 22 | component: 'refine-custom-group-divider', 23 | 24 | // Dont show the divider on the last iteration 25 | class: ({ index, total }) => { 26 | return index === total - 1 ? 'hidden' : 'flex' 27 | }, 28 | }, 29 | 30 | addCriterionButton: { 31 | class: 'text-80 flex items-center', 32 | wrapper: 'text-sm flex items-center p-2', 33 | icon: 'h-4 w-4 -mt-px', 34 | text: 'mt-px', 35 | }, 36 | }, 37 | 38 | addGroupButton: { 39 | class: 'text-sm flex items-center p-2 text-80', 40 | 41 | // Use a custom component, because the default is inexplicably bad. 42 | component: 'refine-custom-or-button', 43 | }, 44 | 45 | condition: 'first:rounded-t-lg bg-white w-full', 46 | 47 | criterion: { 48 | wrapper: { 49 | order: ['errors', 'selector', 'remove'], 50 | class: 'flex border-b border-50 py-3 pl-2 ', 51 | }, 52 | removeCriterionButton: { 53 | class: 'ml-auto py-2 px-4 flex items-center text-60', 54 | icon: 'h-5 w-5', 55 | }, 56 | errors: { 57 | class: 58 | 'flex-1 basis-full bg-red-50 border-l-2 border-red-600 text-red-300 px-4 py-2 rounded list-disc list-inside', 59 | error: 'text-red-600 font-semibold', 60 | }, 61 | }, 62 | 63 | select: { 64 | class: 'relative sm:inline-block w-48 mr-4', 65 | wrapper: 'flex items-start gap-4', 66 | customOptions: { 67 | class: '', 68 | wrapper: 'w-auto pt-4 md:flex md:pt-0', 69 | }, 70 | listbox: { 71 | class: ({ isClosed }) => { 72 | return isClosed 73 | ? 'hidden' 74 | : 'max-h-60 shadow list-reset border border-50 rounded-lg overflow-auto' 75 | }, 76 | 77 | wrapper: 'absolute z-10 w-full mt-1 bg-white rounded-lg shadow-lg', 78 | 79 | item: { 80 | class: ({ isHighlighted }) => { 81 | return `py-2 pr-8 pl-3 relative cursor-pointer select-none ${ 82 | isHighlighted ? 'bg-primary text-white' : '' 83 | }` 84 | }, 85 | 86 | text: options => 87 | `block truncate ${ 88 | options.selected ? 'font-semibold' : 'font-normal' 89 | }`, 90 | 91 | icon: { 92 | class: 'w-5 h-5', 93 | wrapper: options => 94 | `absolute pin-t pin-b pin-r flex items-center pr-4 ${ 95 | options.isHighlighted ? 'text-white' : 'text-blue-600' 96 | }`, 97 | }, 98 | }, 99 | }, 100 | 101 | button: { 102 | class: 'form-control form-select w-full text-left', 103 | placeholder: 'block text-gray-300 truncate select-none', 104 | selected: 'block truncate', 105 | 106 | icon: { 107 | class: 'hidden', 108 | wrapper: 'hidden', 109 | }, 110 | }, 111 | 112 | multi: { 113 | button: { 114 | class: 115 | 'form-control form-select w-full text-left flex items-center overflow-x-auto', 116 | 117 | placeholder: 'block text-gray-300 truncate select-none', 118 | 119 | selected: 'inline-flex mr-1 rounded border border-50 p-1 text-sm', 120 | 121 | icon: { 122 | class: 'hidden', 123 | wrapper: 'hidden', 124 | }, 125 | 126 | deselect: { 127 | icon: { 128 | class: 'w-4 h-4', 129 | wrapper: 'flex items-center ml-1 text-gray-500 cursor-pointer', 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | 136 | inputs: { 137 | date: { 138 | component: 'refine-custom-date-picker', 139 | relative: { 140 | class: `form-control form-input form-input-bordered mr-4`, 141 | wrapper: 'flex mr-4', 142 | }, 143 | 144 | double: { 145 | wrapper: 'flex items-center gap-[1ch]', 146 | joiner: 'mx-2', 147 | }, 148 | }, 149 | 150 | number: { 151 | class: 'form-control form-input form-input-bordered', 152 | 153 | double: { 154 | wrapper: 'flex items-center gap-[1ch]', 155 | joiner: 'ml-2 mr-2', 156 | }, 157 | }, 158 | 159 | text: 'form-control form-input form-input-bordered', 160 | }, 161 | } 162 | 163 | export default novaFlavor 164 | -------------------------------------------------------------------------------- /nova3/resources/js/components/Card.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 198 | -------------------------------------------------------------------------------- /nova4/resources/js/flavors/nova4.js: -------------------------------------------------------------------------------- 1 | const novaFlavor = { 2 | emptyGroup: { 3 | class: 4 | 'rounded-lg shadow p-2 text-gray-400 bg-white flex items-center justify-between text-sm mb-4 dark:bg-gray-800', 5 | 6 | wrapper: { 7 | class: 8 | 'border rounded-lg shadow border-50 p-2 text-gray-400 bg-white flex items-center justify-between text-sm mb-4', 9 | }, 10 | 11 | addCriterionButton: { 12 | class: 'text-sm flex items-center p-2', 13 | 14 | wrapper: {}, 15 | 16 | icon: { 17 | class: 'text-gray-400 h-4 w-4', 18 | }, 19 | 20 | text: { 21 | class: 'pt-px text-gray-400', 22 | }, 23 | }, 24 | }, 25 | 26 | group: { 27 | class: 'rounded-lg shadow', 28 | wrapper: { 29 | class: '', 30 | }, 31 | 32 | divider: { 33 | component: 'custom-group-divider', 34 | // Dont show the divider on the last iteration 35 | class: ({ index, total }) => { 36 | return index === total - 1 ? 'hidden' : 'flex' 37 | }, 38 | }, 39 | 40 | addCriterionButton: { 41 | wrapper: { 42 | class: 'flex items-center p-2 dark:bg-gray-800 rounded-b-lg', 43 | }, 44 | 45 | class: 'text-gray-400 flex items-center', 46 | 47 | icon: { 48 | class: 'h-4 w-4 -mt-px', 49 | }, 50 | 51 | text: { 52 | class: 'mt-px text-xs font-bold', 53 | }, 54 | }, 55 | }, 56 | 57 | addGroupButton: { 58 | class: 'text-sm flex items-center p-2 text-gray-400', 59 | 60 | // Use a custom component, because the default is inexplicably bad. 61 | component: 'custom-or-button', 62 | }, 63 | 64 | condition: { 65 | class: 'first:rounded-t-lg bg-white dark:bg-gray-800 w-full', 66 | }, 67 | 68 | criterion: { 69 | wrapper: { 70 | order: ['selector', 'remove', 'errors'], 71 | class: 72 | 'flex flex-wrap border-b border-gray-100 dark:border-gray-700 py-3 pl-2', 73 | }, 74 | removeCriterionButton: { 75 | class: 76 | 'ml-auto py-2 px-4 flex items-center text-gray-300 hover:text-gray-500 dark:hover:text-white', 77 | icon: { 78 | class: 'h-5 w-5', 79 | }, 80 | }, 81 | errors: { 82 | class: 'w-full list-none help-text mt-2 help-text-error', 83 | error: { 84 | class: '', 85 | }, 86 | }, 87 | }, 88 | 89 | select: { 90 | class: 'relative sm:inline-block w-48 mr-4', 91 | 92 | wrapper: { 93 | class: 'flex items-start', 94 | }, 95 | 96 | customOptions: { 97 | class: '', 98 | wrapper: { 99 | class: 'w-auto pt-4 md:flex md:pt-0', 100 | }, 101 | }, 102 | 103 | listbox: { 104 | class: ({ isClosed }) => { 105 | return isClosed 106 | ? 'hidden' 107 | : 'focus:outline-none max-h-60 shadow list-reset rounded overflow-auto' 108 | }, 109 | 110 | wrapper: { 111 | class: 112 | 'absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 rounded shadow-lg', 113 | }, 114 | 115 | item: { 116 | class: ({ isHighlighted }) => { 117 | return `py-2 pr-8 pl-3 relative cursor-pointer select-none ${ 118 | isHighlighted ? 'bg-primary-600 text-white' : '' 119 | }` 120 | }, 121 | 122 | text: { 123 | class: options => 124 | `block truncate ${ 125 | options.selected ? 'font-semibold' : 'font-normal' 126 | }`, 127 | }, 128 | 129 | icon: { 130 | class: 'w-5 h-5', 131 | wrapper: { 132 | class: options => 133 | `absolute top-0 bottom-0 right-0 flex items-center pr-4 ${ 134 | !options.isHighlighted 135 | ? 'text-blue-600 dark:text-gray-400' 136 | : 'text-white dark:text-white' 137 | }`, 138 | }, 139 | }, 140 | }, 141 | }, 142 | 143 | button: { 144 | class: 145 | 'w-full block form-control form-select text-left form-select-bordered flex items-center', 146 | 147 | placeholder: { 148 | class: 'block text-gray-300 truncate select-none', 149 | }, 150 | 151 | selected: { 152 | class: 'block truncate w-full', 153 | }, 154 | 155 | icon: { 156 | wrapper: { 157 | class: 'right-0 absolute mr-3', 158 | }, 159 | component: 'custom-select-icon', 160 | }, 161 | }, 162 | 163 | multi: { 164 | button: { 165 | class: 166 | 'form-control form-select w-full text-left flex items-center overflow-x-auto', 167 | 168 | placeholder: { 169 | class: 'block text-gray-300 truncate select-none', 170 | }, 171 | 172 | selected: { 173 | class: 'inline-flex mr-1 rounded border border-50 p-1 text-sm', 174 | }, 175 | 176 | icon: { 177 | class: 'hidden', 178 | wrapper: { 179 | class: 'hidden', 180 | }, 181 | }, 182 | 183 | deselect: { 184 | icon: { 185 | class: 'w-4 h-4', 186 | 187 | wrapper: { 188 | class: 'flex items-center ml-1 text-gray-500 cursor-pointer', 189 | }, 190 | }, 191 | }, 192 | }, 193 | }, 194 | }, 195 | 196 | inputs: { 197 | date: { 198 | class: 'form-control form-input form-input-bordered', 199 | relative: { 200 | class: `form-control form-input form-input-bordered mr-4`, 201 | 202 | wrapper: { 203 | class: 'flex mr-4', 204 | }, 205 | }, 206 | 207 | double: { 208 | wrapper: { 209 | class: 'flex items-center gap-[1ch]', 210 | }, 211 | 212 | joiner: {}, 213 | }, 214 | }, 215 | 216 | number: { 217 | class: 'form-control form-input form-input-bordered', 218 | 219 | double: { 220 | wrapper: { 221 | class: 'flex items-center gap-[1ch]', 222 | }, 223 | 224 | joiner: { 225 | class: 'ml-2 mr-2', 226 | }, 227 | }, 228 | }, 229 | 230 | text: { 231 | class: 'form-control form-input form-input-bordered', 232 | }, 233 | }, 234 | } 235 | 236 | export default novaFlavor 237 | -------------------------------------------------------------------------------- /nova4/resources/js/components/Card.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 228 | -------------------------------------------------------------------------------- /nova4/dist/css/card.css: -------------------------------------------------------------------------------- 1 | .refine-nova-card .pointer-events-none { 2 | pointer-events: none 3 | } 4 | .refine-nova-card .absolute { 5 | position: absolute 6 | } 7 | .refine-nova-card .relative { 8 | position: relative 9 | } 10 | .refine-nova-card .top-0 { 11 | top: 0px 12 | } 13 | .refine-nova-card .bottom-0 { 14 | bottom: 0px 15 | } 16 | .refine-nova-card .right-0 { 17 | right: 0px 18 | } 19 | .refine-nova-card .z-10 { 20 | z-index: 10 21 | } 22 | .refine-nova-card .my-2 { 23 | margin-top: 0.5rem; 24 | margin-bottom: 0.5rem 25 | } 26 | .refine-nova-card .mx-2 { 27 | margin-left: 0.5rem; 28 | margin-right: 0.5rem 29 | } 30 | .refine-nova-card .mr-6 { 31 | margin-right: 1.5rem 32 | } 33 | .refine-nova-card .mr-1 { 34 | margin-right: 0.25rem 35 | } 36 | .refine-nova-card .-mt-px { 37 | margin-top: -1px 38 | } 39 | .refine-nova-card .mb-4 { 40 | margin-bottom: 1rem 41 | } 42 | .refine-nova-card .mt-px { 43 | margin-top: 1px 44 | } 45 | .refine-nova-card .ml-auto { 46 | margin-left: auto 47 | } 48 | .refine-nova-card .mt-2 { 49 | margin-top: 0.5rem 50 | } 51 | .refine-nova-card .mr-4 { 52 | margin-right: 1rem 53 | } 54 | .refine-nova-card .mt-1 { 55 | margin-top: 0.25rem 56 | } 57 | .refine-nova-card .mr-3 { 58 | margin-right: 0.75rem 59 | } 60 | .refine-nova-card .ml-1 { 61 | margin-left: 0.25rem 62 | } 63 | .refine-nova-card .ml-2 { 64 | margin-left: 0.5rem 65 | } 66 | .refine-nova-card .mr-2 { 67 | margin-right: 0.5rem 68 | } 69 | .refine-nova-card .block { 70 | display: block 71 | } 72 | .refine-nova-card .flex { 73 | display: flex 74 | } 75 | .refine-nova-card .inline-flex { 76 | display: inline-flex 77 | } 78 | .refine-nova-card .hidden { 79 | display: none 80 | } 81 | .refine-nova-card .h-9 { 82 | height: 2.25rem 83 | } 84 | .refine-nova-card .h-4 { 85 | height: 1rem 86 | } 87 | .refine-nova-card .h-5 { 88 | height: 1.25rem 89 | } 90 | .refine-nova-card .max-h-60 { 91 | max-height: 15rem 92 | } 93 | .refine-nova-card .w-4 { 94 | width: 1rem 95 | } 96 | .refine-nova-card .w-full { 97 | width: 100% 98 | } 99 | .refine-nova-card .w-5 { 100 | width: 1.25rem 101 | } 102 | .refine-nova-card .w-48 { 103 | width: 12rem 104 | } 105 | .refine-nova-card .w-auto { 106 | width: auto 107 | } 108 | .refine-nova-card .flex-shrink-0 { 109 | flex-shrink: 0 110 | } 111 | .refine-nova-card .cursor-pointer { 112 | cursor: pointer 113 | } 114 | .refine-nova-card .select-none { 115 | -webkit-user-select: none; 116 | -moz-user-select: none; 117 | user-select: none 118 | } 119 | .refine-nova-card .list-none { 120 | list-style-type: none 121 | } 122 | .refine-nova-card .flex-wrap { 123 | flex-wrap: wrap 124 | } 125 | .refine-nova-card .items-start { 126 | align-items: flex-start 127 | } 128 | .refine-nova-card .items-center { 129 | align-items: center 130 | } 131 | .refine-nova-card .justify-between { 132 | justify-content: space-between 133 | } 134 | .refine-nova-card .gap-\[1ch\] { 135 | gap: 1ch 136 | } 137 | .refine-nova-card .overflow-auto { 138 | overflow: auto 139 | } 140 | .refine-nova-card .overflow-x-auto { 141 | overflow-x: auto 142 | } 143 | .refine-nova-card .truncate { 144 | overflow: hidden; 145 | text-overflow: ellipsis; 146 | white-space: nowrap 147 | } 148 | .refine-nova-card .rounded { 149 | border-radius: 0.25rem 150 | } 151 | .refine-nova-card .rounded-lg { 152 | border-radius: 0.5rem 153 | } 154 | .refine-nova-card .rounded-b-lg { 155 | border-bottom-right-radius: 0.5rem; 156 | border-bottom-left-radius: 0.5rem 157 | } 158 | .refine-nova-card .border { 159 | border-width: 1px 160 | } 161 | .refine-nova-card .border-t { 162 | border-top-width: 1px 163 | } 164 | .refine-nova-card .border-b { 165 | border-bottom-width: 1px 166 | } 167 | .refine-nova-card .border-gray-100 { 168 | border-color: rgba(var(--colors-gray-100)) 169 | } 170 | .refine-nova-card .bg-primary-500 { 171 | background-color: rgba(var(--colors-primary-500)) 172 | } 173 | .refine-nova-card .bg-white { 174 | --tw-bg-opacity: 1; 175 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)) 176 | } 177 | .refine-nova-card .bg-primary-600 { 178 | background-color: rgba(var(--colors-primary-600)) 179 | } 180 | .refine-nova-card .fill-current { 181 | fill: currentColor 182 | } 183 | .refine-nova-card .p-4 { 184 | padding: 1rem 185 | } 186 | .refine-nova-card .p-2 { 187 | padding: 0.5rem 188 | } 189 | .refine-nova-card .p-1 { 190 | padding: 0.25rem 191 | } 192 | .refine-nova-card .px-4 { 193 | padding-left: 1rem; 194 | padding-right: 1rem 195 | } 196 | .refine-nova-card .py-3 { 197 | padding-top: 0.75rem; 198 | padding-bottom: 0.75rem 199 | } 200 | .refine-nova-card .py-2 { 201 | padding-top: 0.5rem; 202 | padding-bottom: 0.5rem 203 | } 204 | .refine-nova-card .pl-4 { 205 | padding-left: 1rem 206 | } 207 | .refine-nova-card .pt-px { 208 | padding-top: 1px 209 | } 210 | .refine-nova-card .pl-2 { 211 | padding-left: 0.5rem 212 | } 213 | .refine-nova-card .pt-4 { 214 | padding-top: 1rem 215 | } 216 | .refine-nova-card .pr-8 { 217 | padding-right: 2rem 218 | } 219 | .refine-nova-card .pl-3 { 220 | padding-left: 0.75rem 221 | } 222 | .refine-nova-card .pr-4 { 223 | padding-right: 1rem 224 | } 225 | .refine-nova-card .text-left { 226 | text-align: left 227 | } 228 | .refine-nova-card .text-right { 229 | text-align: right 230 | } 231 | .refine-nova-card .text-sm { 232 | font-size: 0.875rem; 233 | line-height: 1.25rem 234 | } 235 | .refine-nova-card .text-xs { 236 | font-size: 0.75rem; 237 | line-height: 1rem 238 | } 239 | .refine-nova-card .font-bold { 240 | font-weight: 700 241 | } 242 | .refine-nova-card .font-semibold { 243 | font-weight: 600 244 | } 245 | .refine-nova-card .font-normal { 246 | font-weight: 400 247 | } 248 | .refine-nova-card .text-white { 249 | --tw-text-opacity: 1; 250 | color: rgb(255 255 255 / var(--tw-text-opacity)) 251 | } 252 | .refine-nova-card .text-gray-400 { 253 | color: rgba(var(--colors-gray-400)) 254 | } 255 | .refine-nova-card .text-gray-300 { 256 | color: rgba(var(--colors-gray-300)) 257 | } 258 | .refine-nova-card .text-blue-600 { 259 | color: rgba(var(--colors-blue-600)) 260 | } 261 | .refine-nova-card .text-gray-500 { 262 | color: rgba(var(--colors-gray-500)) 263 | } 264 | .refine-nova-card .shadow { 265 | --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 266 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 267 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) 268 | } 269 | .refine-nova-card .shadow-lg { 270 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 271 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 272 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) 273 | } 274 | .refine-nova-card .ring-primary-200 { 275 | --tw-ring-color: rgba(var(--colors-primary-200)) 276 | } 277 | .refine-nova-card .filter { 278 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) 279 | } 280 | .refine-nova-card .transition { 281 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 282 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 283 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 284 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 285 | transition-duration: 150ms 286 | } 287 | .refine-nova-card .first\:rounded-t-lg:first-child { 288 | border-top-left-radius: 0.5rem; 289 | border-top-right-radius: 0.5rem 290 | } 291 | .refine-nova-card .hover\:bg-primary-400:hover { 292 | background-color: rgba(var(--colors-primary-400)) 293 | } 294 | .refine-nova-card .hover\:text-gray-500:hover { 295 | color: rgba(var(--colors-gray-500)) 296 | } 297 | .refine-nova-card .focus\:outline-none:focus { 298 | outline: 2px solid transparent; 299 | outline-offset: 2px 300 | } 301 | .refine-nova-card .focus\:ring:focus { 302 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 303 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); 304 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000) 305 | } 306 | .refine-nova-card .active\:bg-primary-600:active { 307 | background-color: rgba(var(--colors-primary-600)) 308 | } 309 | .refine-nova-card .dark .dark\:border-gray-800 { 310 | border-color: rgba(var(--colors-gray-800)) 311 | } 312 | .refine-nova-card .dark .dark\:border-gray-700 { 313 | border-color: rgba(var(--colors-gray-700)) 314 | } 315 | .refine-nova-card .dark .dark\:bg-gray-800 { 316 | background-color: rgba(var(--colors-gray-800)) 317 | } 318 | .refine-nova-card .dark .dark\:text-gray-800 { 319 | color: rgba(var(--colors-gray-800)) 320 | } 321 | .refine-nova-card .dark .dark\:text-gray-400 { 322 | color: rgba(var(--colors-gray-400)) 323 | } 324 | .refine-nova-card .dark .dark\:text-white { 325 | --tw-text-opacity: 1; 326 | color: rgb(255 255 255 / var(--tw-text-opacity)) 327 | } 328 | .refine-nova-card .dark .dark\:ring-gray-600 { 329 | --tw-ring-color: rgba(var(--colors-gray-600)) 330 | } 331 | .refine-nova-card .dark .dark\:hover\:text-white:hover { 332 | --tw-text-opacity: 1; 333 | color: rgb(255 255 255 / var(--tw-text-opacity)) 334 | } 335 | @media (min-width: 640px) { 336 | .refine-nova-card .sm\:inline-block { 337 | display: inline-block 338 | } 339 | } 340 | @media (min-width: 768px) { 341 | .refine-nova-card .md\:flex { 342 | display: flex 343 | } 344 | .refine-nova-card .md\:pt-0 { 345 | padding-top: 0px 346 | } 347 | } 348 | 349 | --------------------------------------------------------------------------------