├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── logs └── v1.6.0.txt ├── php-diff └── SideBySide.php └── src ├── Commands ├── GarbageCollector.php └── PackageSetup.php ├── Controllers └── OdinController.php ├── Odin.php ├── OdinRoutes.php ├── OdinServiceProvider.php ├── Traits └── Revisions.php ├── database └── 2018_05_18_051821_create_audits_pivot_table.php └── resources ├── assets ├── js │ ├── Odin.vue │ ├── animated-scroll-to.js │ └── manager.js └── sass │ ├── packages │ ├── animate.scss │ └── bulma-timeline.css │ └── style.scss ├── lang └── en │ └── messages.php └── views └── list.blade.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ctf0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Muah 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Odin 3 |
4 | Latest Stable Version Total Downloads 5 |

6 | 7 | Manage model revisions with ease. 8 | > If you are also looking to preview the form data before submitting to the db, you may want to give [OverSeer](https://github.com/ctf0/OverSeer) a try. 9 | 10 |

11 | 12 |

13 | 14 | - package requires Laravel v5.4+ 15 | 16 |
17 | 18 | ## Installation 19 | 20 | - `composer require ctf0/odin` 21 | 22 | - (Laravel < 5.5) add the service provider & facade 23 | 24 | ```php 25 | 'providers' => [ 26 | ctf0\Odin\OdinServiceProvider::class, 27 | ]; 28 | ``` 29 | 30 | * publish the package assets with 31 | 32 | `php artisan vendor:publish --provider="ctf0\Odin\OdinServiceProvider"` 33 | 34 | - after installation, run `php artisan odin:setup` to add 35 | + package routes to `routes/web.php` 36 | + package assets compiling to `webpack.mix.js` 37 | 38 | - check [laravel-auditing docs](http://www.laravel-auditing.com/docs/master/general-configuration) for configuration 39 | 40 | - install dependencies 41 | 42 | ```bash 43 | yarn add vue vue-awesome@v2 vue-notif axios keycode 44 | ``` 45 | 46 | - add this one liner to your main js file and run `npm run watch` to compile your `js/css` files. 47 | + if you are having issues [Check](https://ctf0.wordpress.com/2017/09/12/laravel-mix-es6/). 48 | 49 | ```js 50 | // app.js 51 | 52 | window.Vue = require('vue') 53 | 54 | require('../vendor/Odin/js/manager') 55 | 56 | new Vue({ 57 | el: '#app' 58 | }) 59 | ``` 60 | 61 |
62 | 63 | ## Features 64 | 65 | - support single & nested values. 66 | - delete & restore revisions. 67 | - support soft deletes. 68 | - [revision preview](https://github.com/ctf0/Odin/wiki/Preview-Revision). 69 | - clear audits for permanently deleted models. 70 | 71 | ```bash 72 | php artisan odin:gc 73 | ``` 74 | 75 | + which can be scheduled as well 76 | ```php 77 | $schedule->command('odin:gc')->sundays(); 78 | ``` 79 | 80 | - shortcuts 81 | 82 | | navigation | keyboard | mouse (click) | 83 | |----------------------|------------|---------------------| 84 | | go to next revision | right/down | * *(revision date)* | 85 | | go to prev revision | left/up | * *(revision date)* | 86 | | go to first revision | home | * *(revision date)* | 87 | | go to last revision | end | * *(revision date)* | 88 | | hide revision window | esc | * *(x)* | 89 | 90 | - events "[JS](https://github.com/gocanto/vuemit)" 91 | 92 | | event-name | description | 93 | |------------|-------------------------| 94 | | odin-show | when revision is showen | 95 | | odin-hide | when revision is hidden | 96 | 97 |
98 | 99 | ## Usage 100 | 101 | - run `php artisan migrate` 102 | 103 | - add `Revisions` trait & `AuditableContract` contract to your model 104 | + for `User model` [Check](http://laravel-auditing.com/docs/master/audit-resolvers) 105 | 106 | ```php 107 | 108 | use ctf0\Odin\Traits\Revisions; 109 | use Illuminate\Database\Eloquent\Model; 110 | use OwenIt\Auditing\Contracts\Auditable as AuditableContract; 111 | 112 | class Post extends Model implements AuditableContract 113 | { 114 | use Revisions; 115 | 116 | /** 117 | * resolve model title/name for the revision relation 118 | * this is needed so we can render 119 | * the model relation attach/detach changes 120 | */ 121 | public function getMiscTitleAttribute() 122 | { 123 | return $this->name; 124 | } 125 | 126 | // ... 127 | } 128 | ``` 129 | 130 | - you can disable creating **ghost** audits where both `old/new` values are empty by using 131 | + remember that without the parent model audit log we cant show the relation changes 132 | 133 | ```php 134 | // app/Providers/EventServiceProvider 135 | 136 | use OwenIt\Auditing\Models\Audit; 137 | 138 | public function boot() 139 | { 140 | parent::boot(); 141 | 142 | Audit::creating(function (Audit $model) { 143 | if (empty($model->old_values) && empty($model->new_values)) { 144 | return false; 145 | } 146 | }); 147 | } 148 | ``` 149 | 150 | - inside the model view ex.`post edit view` add 151 | 152 | ```blade 153 | @if (count($post->revisionsWithRelation)) 154 | @include('Odin::list', ['revisions' => $post->revisionsWithRelation]) 155 | @endif 156 | ``` 157 | 158 |
159 | 160 | ## Notes 161 | 162 | - model `user_id` & `id` are excluded from the audit log by default. 163 | 164 | - **data:uri** 165 | - if you use `data:uri` in your revisionable content, change [`audits_table`](https://github.com/owen-it/laravel-auditing/blob/958a6edd4cd4f9d61aa34f288f708644e150e866/database/migrations/audits.stub#L33-L34) columns type to either `mediumText` or `longText` before migrating to avoid future errors of long data. 166 | 167 | - because `data:uri` is a render blocking & isn't readable by humans, we truncate it to 75 char max
168 | note that this ***ONLY*** effects the displaying of the revision diff, we never touch the data that gets saved to the db. 169 | 170 | - **model-relation** 171 | + atm the relation revision is limited, it means we can only show the `attach/detach` changes but we cant `undo/redo` any of them through the package it self. 172 | + also if you use mass update like `Model::update()` make sure to call `$model->touch();` afterwards to make sure an audit is created ex. 173 | ```php 174 | $model = Model::update([...]); 175 | $model->touch(); 176 | ``` 177 | 178 |
179 | 180 | ### Security 181 | 182 | If you discover any security-related issues, please email [ctf0-dev@protonmail.com](mailto:ctf0-dev@protonmail.com). 183 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctf0/odin", 3 | "description": "GUI to manage model revisions", 4 | "homepage": "https://github.com/ctf0/Odin", 5 | "license": "MIT", 6 | "keywords": [ 7 | "ctf0", 8 | "diff", 9 | "laravel", 10 | "model", 11 | "revisions", 12 | "gui", 13 | "manager" 14 | ], 15 | "authors": [ 16 | { 17 | "name": "Muah", 18 | "email": "muah003@gmail.com" 19 | } 20 | ], 21 | "require": { 22 | "illuminate/support": ">=5.4 <9.0", 23 | "phpspec/php-diff": "^1.1", 24 | "owen-it/laravel-auditing": "*", 25 | "fico7489/laravel-pivot": "*", 26 | "ctf0/package-changelog": "*" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "ctf0\\Odin\\": "src" 31 | } 32 | }, 33 | "extra": { 34 | "laravel": { 35 | "providers": [ 36 | "ctf0\\Odin\\OdinServiceProvider" 37 | ] 38 | }, 39 | "changeLog": "logs" 40 | }, 41 | "config": { 42 | "sort-packages": true 43 | }, 44 | "scripts": { 45 | "post-package-install": [ 46 | "@php artisan vendor:publish --provider=\"ctf0\\Odin\\OdinServiceProvider\"", 47 | "@php artisan auditing:install" 48 | ] 49 | }, 50 | "funding": [ 51 | { 52 | "type": "github", 53 | "url": "https://github.com/sponsors/ctf0" 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /logs/v1.6.0.txt: -------------------------------------------------------------------------------- 1 | - fix the check between the pivot record and the parent because of incorrect dates “note that the audits_pivot_table has been updated along with the revision relation aggregator” 2 | 3 | - combine the relation changes under the same model name instead of rendering them individually -------------------------------------------------------------------------------- /php-diff/SideBySide.php: -------------------------------------------------------------------------------- 1 | 8 | * 9 | * All rights reserved. 10 | * 11 | * Redistribution and use in source and binary forms, with or without 12 | * modification, are permitted provided that the following conditions are met: 13 | * 14 | * - Redistributions of source code must retain the above copyright notice, 15 | * this list of conditions and the following disclaimer. 16 | * - Redistributions in binary form must reproduce the above copyright notice, 17 | * this list of conditions and the following disclaimer in the documentation 18 | * and/or other materials provided with the distribution. 19 | * - Neither the name of the Chris Boulton nor the names of its contributors 20 | * may be used to endorse or promote products derived from this software 21 | * without specific prior written permission. 22 | * 23 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 24 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 25 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 26 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 27 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 28 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 29 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 30 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 31 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 32 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 33 | * POSSIBILITY OF SUCH DAMAGE. 34 | * 35 | * @author Chris Boulton 36 | * @copyright (c) 2009 Chris Boulton 37 | * @license New BSD License http://www.opensource.org/licenses/bsd-license.php 38 | * 39 | * @version 1.1 40 | * 41 | * @see http://github.com/chrisboulton/php-diff 42 | */ 43 | class Diff_Renderer_Html_SideBySide extends Diff_Renderer_Html_Array 44 | { 45 | /** 46 | * Render a and return diff with changes between the two sequences 47 | * displayed side by side. 48 | * 49 | * @param mixed $col 50 | * 51 | * @return string the generated side by side diff 52 | */ 53 | public function render() 54 | { 55 | $changes = parent::render(); 56 | 57 | $html = ''; 58 | if (empty($changes)) { 59 | return $html; 60 | } 61 | 62 | $html .= ''; 63 | 64 | foreach ($changes as $i => $blocks) { 65 | if ($i > 0) { 66 | $html .= ''; 67 | $html .= ''; 68 | $html .= ''; 69 | $html .= ''; 70 | } 71 | 72 | foreach ($blocks as $change) { 73 | $html .= ''; 74 | // Equal changes should be shown on both sides of the diff 75 | if ($change['tag'] == 'equal') { 76 | foreach ($change['base']['lines'] as $no => $line) { 77 | $fromLine = $change['base']['offset'] + $no + 1; 78 | $toLine = $change['changed']['offset'] + $no + 1; 79 | $html .= ''; 80 | $html .= ''; 81 | $html .= ''; 82 | $html .= ''; 83 | } 84 | } 85 | // Added lines only on the right side 86 | elseif ($change['tag'] == 'insert') { 87 | foreach ($change['changed']['lines'] as $no => $line) { 88 | $toLine = $change['changed']['offset'] + $no + 1; 89 | $html .= ''; 90 | $html .= ''; 91 | $html .= ''; 92 | $html .= ''; 93 | } 94 | } 95 | // Show deleted lines only on the left side 96 | elseif ($change['tag'] == 'delete') { 97 | foreach ($change['base']['lines'] as $no => $line) { 98 | $fromLine = $change['base']['offset'] + $no + 1; 99 | $html .= ''; 100 | $html .= ''; 101 | $html .= ''; 102 | $html .= ''; 103 | } 104 | } 105 | // Show modified lines on both sides 106 | elseif ($change['tag'] == 'replace') { 107 | if (count($change['base']['lines']) >= count($change['changed']['lines'])) { 108 | foreach ($change['base']['lines'] as $no => $line) { 109 | $fromLine = $change['base']['offset'] + $no + 1; 110 | $html .= ''; 111 | $html .= ''; 112 | if (isset($change['changed']['lines'][$no])) { 113 | $toLine = $change['base']['offset'] + $no + 1; 114 | $changedLine = $change['changed']['lines'][$no]; 115 | } 116 | $html .= ''; 117 | $html .= ''; 118 | } 119 | } else { 120 | foreach ($change['changed']['lines'] as $no => $changedLine) { 121 | if (isset($change['base']['lines'][$no])) { 122 | $fromLine = $change['base']['offset'] + $no + 1; 123 | $line = $change['base']['lines'][$no]; 124 | } 125 | $html .= ''; 126 | $html .= ''; 127 | $toLine = $change['changed']['offset'] + $no + 1; 128 | $html .= ''; 129 | $html .= ''; 130 | } 131 | } 132 | } 133 | $html .= ''; 134 | } 135 | } 136 | $html .= '
' . $line . '' . $line . '
' . $line . '
' . $line . '
' . $line . '' . $changedLine . '
' . $line . '' . $changedLine . '
'; 137 | 138 | return $html; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Commands/GarbageCollector.php: -------------------------------------------------------------------------------- 1 | groupBy('auditable_type')->pluck('auditable_type'); 30 | $cleared = false; 31 | 32 | foreach ($models as $m) { 33 | $audit_ids = $audit->where('auditable_type', $m)->groupBy('auditable_id')->pluck('auditable_id')->all(); 34 | $model_ids = app($m)->withoutGlobalScopes()->pluck('id')->all(); 35 | $diff = array_diff($audit_ids, $model_ids); 36 | 37 | if (count($diff) > 0) { 38 | $cleared = true; 39 | 40 | $audit->where('auditable_type', $m) 41 | ->whereIn('auditable_id', $diff) 42 | ->delete(); 43 | 44 | $this->line("'$m' audits are cleared"); 45 | } 46 | } 47 | 48 | $cleared 49 | ? $this->info('All Done') 50 | : $this->info('Nothing To Clear'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Commands/PackageSetup.php: -------------------------------------------------------------------------------- 1 | file = app('files'); 20 | 21 | parent::__construct(); 22 | } 23 | 24 | /** 25 | * Execute the console command. 26 | * 27 | * @return mixed 28 | */ 29 | public function handle() 30 | { 31 | // routes 32 | $route_file = base_path('routes/web.php'); 33 | $search = 'Odin'; 34 | 35 | if ($this->checkExist($route_file, $search)) { 36 | $data = "\n// Odin\nctf0\Odin\OdinRoutes::routes();"; 37 | 38 | $this->file->append($route_file, $data); 39 | } 40 | 41 | // mix 42 | $mix_file = base_path('webpack.mix.js'); 43 | $search = 'Odin'; 44 | 45 | if ($this->checkExist($mix_file, $search)) { 46 | $data = "\n// Odin\nmix.sass('resources/assets/vendor/Odin/sass/style.scss', 'public/assets/vendor/Odin/style.css')"; 47 | 48 | $this->file->append($mix_file, $data); 49 | } 50 | 51 | $this->info('All Done'); 52 | } 53 | 54 | /** 55 | * [checkExist description]. 56 | * 57 | * @param [type] $file [description] 58 | * @param [type] $search [description] 59 | * 60 | * @return [type] [description] 61 | */ 62 | protected function checkExist($file, $search) 63 | { 64 | return $this->file->exists($file) && !Str::contains($this->file->get($file), $search); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Controllers/OdinController.php: -------------------------------------------------------------------------------- 1 | getId($id); 20 | 21 | if (in_array($revision->event, ['created', 'restored'])) { 22 | return back()->with([ 23 | 'title' => 'Error', 24 | 'status' => trans('Odin::messages.cant_preview'), 25 | 'type' => 'danger', 26 | ]); 27 | } 28 | 29 | $model = app($revision->auditable_type); 30 | $data = method_exists($model, 'isForceDeleting') 31 | ? $model->withTrashed()->findOrFail($revision->auditable_id)->transitionTo($revision, true) 32 | : $model->findOrFail($revision->auditable_id)->transitionTo($revision, true); 33 | 34 | return view(request('template'), compact('data')); 35 | } 36 | 37 | /** 38 | * restore a revision. 39 | * 40 | * @param mixed $id 41 | * 42 | * @return [type] [description] 43 | */ 44 | public function restore($id) 45 | { 46 | $revision = $this->getId($id); 47 | 48 | if (in_array($revision->event, ['deleted', 'restored'])) { 49 | return back()->with([ 50 | 'title' => 'Error', 51 | 'status' => trans('Odin::messages.cant_restore'), 52 | 'type' => 'danger', 53 | ]); 54 | } 55 | 56 | $model = app($revision->auditable_type)->findOrFail($revision->auditable_id); 57 | $revision->event == 'created' 58 | ? $model->transitionTo($revision) // use new 59 | : $model->transitionTo($revision, true); // use old 60 | 61 | $model->save() 62 | ? session()->flash('status', trans('Odin::messages.res_success')) 63 | : session()->flash([ 64 | 'title' => 'Error', 65 | 'status' => trans('Odin::messages.went_bad'), 66 | 'type' => 'danger', 67 | ]); 68 | 69 | return back(); 70 | } 71 | 72 | /** 73 | * restore soft deleted model. 74 | * 75 | * @param [type] $id [description] 76 | * 77 | * @return [type] [description] 78 | */ 79 | public function restoreSoft($id) 80 | { 81 | $revision = $this->getId($id); 82 | 83 | if (!in_array($revision->event, ['deleted'])) { 84 | return back()->with([ 85 | 'title' => 'Error', 86 | 'status' => trans('Odin::messages.cant_soft_restore'), 87 | 'type' => 'danger', 88 | ]); 89 | } 90 | 91 | $model = app($revision->auditable_type)->withTrashed()->findOrFail($revision->auditable_id); 92 | 93 | $model->restore() 94 | ? session()->flash('status', trans('Odin::messages.res_model_success')) 95 | : session()->flash([ 96 | 'title' => 'Error', 97 | 'status' => trans('Odin::messages.went_bad'), 98 | 'type' => 'danger', 99 | ]); 100 | 101 | return back(); 102 | } 103 | 104 | /** 105 | * delete a revision. 106 | * 107 | * @param [type] $id [description] 108 | * 109 | * @return [type] [description] 110 | */ 111 | public function remove($id) 112 | { 113 | if ($this->getId($id)->delete()) { 114 | return response()->json([ 115 | 'success'=> true, 116 | 'message'=> trans('Odin::messages.del_success'), 117 | ]); 118 | } 119 | 120 | return response()->json([ 121 | 'success'=> false, 122 | 'message'=> trans('Odin::messages.went_bad'), 123 | ]); 124 | } 125 | 126 | /** 127 | * helper. 128 | * 129 | * @param [type] $id [description] 130 | * 131 | * @return [type] [description] 132 | */ 133 | protected function getId($id) 134 | { 135 | return Audit::findOrFail($id); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Odin.php: -------------------------------------------------------------------------------- 1 | getModified() as $col => $data) { 31 | $data['new'] = json_decode(array_get($rev->new_values, $col), true) ?: array_get($rev->new_values, $col); 32 | $data['old'] = json_decode(array_get($rev->old_values, $col), true) ?: array_get($rev->old_values, $col); 33 | 34 | // put old first 35 | if (array_keys($data)[0] == 'new') { 36 | $data = array_reverse($data); 37 | } 38 | 39 | $old = ''; 40 | $new = ''; 41 | 42 | if (array_key_exists('new', $data)) { 43 | $new = $data['new']; 44 | 45 | if (is_array($new)) { 46 | ksort($new); 47 | $multi[$col]['new'] = $new; 48 | } 49 | } 50 | 51 | if (array_key_exists('old', $data)) { 52 | $old = $data['old']; 53 | 54 | if (is_array($old)) { 55 | ksort($old); 56 | $multi[$col]['old'] = $old; 57 | } 58 | } 59 | 60 | if (is_array($new) || is_array($old) || (empty($old) && empty($new))) { 61 | continue; 62 | } 63 | 64 | $str .= "

$col

"; 65 | $str .= $this->renderDiff($old, $new); 66 | } 67 | 68 | // if multi locale 69 | if ($multi) { 70 | foreach ($multi as $col => $data) { 71 | // make sure we have both "old & new" 72 | if (!array_key_exists('new', $data)) { 73 | $data['new'] = array_fill_keys(array_keys($data['old']), null); 74 | } 75 | 76 | if (!array_key_exists('old', $data)) { 77 | $data['old'] = array_fill_keys(array_keys($data['new']), null); 78 | } 79 | 80 | // make sure the keys count is the same in both "old & new" 81 | $new_keys = array_keys($data['new']); 82 | $old_keys = array_keys($data['old']); 83 | 84 | if (count($new_keys) > count($old_keys)) { 85 | foreach (array_diff($new_keys, $old_keys) as $key) { 86 | $data['old'][$key] = null; 87 | } 88 | } 89 | 90 | // put old first 91 | if (array_keys($data)[0] == 'new') { 92 | $data = array_reverse($data); 93 | } 94 | 95 | // combine old & new of each key 96 | $output = []; 97 | array_walk_recursive($data, function ($value, $key) use (&$output) { 98 | $output[$key][] = $value; 99 | }); 100 | 101 | // avoid duplicating the main title on each iteration in case column have sub keys 102 | $exist = []; 103 | 104 | // render 105 | foreach ($output as $e => $v) { 106 | $old = isset($v[0]) ? $v[0] : ''; 107 | $new = isset($v[1]) ? $v[1] : ''; 108 | 109 | if ($res = $this->renderDiff($old, $new)) { 110 | if (!in_array($col, $exist)) { 111 | $str .= "

$col

"; 112 | } 113 | 114 | $str .= "

$e

"; 115 | $str .= $res; 116 | 117 | $exist[] = $col; 118 | } 119 | } 120 | } 121 | } 122 | 123 | return $str; 124 | } 125 | 126 | /** 127 | * Compares two strings or string arrays, and return their differences. 128 | * This is a wrapper of the [phpspec/php-diff]. 129 | * 130 | * @param string|array $old the first string or string array to be compared. If it is a string, 131 | * it will be converted into a string array by breaking at newlines. 132 | * @param string|array $new the second string or string array to be compared. If it is a string, 133 | * it will be converted into a string array by breaking at newlines. 134 | * 135 | * @return string the comparison result 136 | */ 137 | protected function renderDiff($old, $new) 138 | { 139 | if (!is_array($old)) { 140 | $old = explode("\n", $old); 141 | } 142 | if (!is_array($new)) { 143 | $new = explode("\n", $new); 144 | } 145 | 146 | foreach ($old as $i => $line) { 147 | $old[$i] = rtrim($line, "\r\n"); 148 | } 149 | foreach ($new as $i => $line) { 150 | $new[$i] = rtrim($line, "\r\n"); 151 | } 152 | 153 | $old = preg_replace('/(?<=data:image.{65}).*?"/', '..."', $old); 154 | $new = preg_replace('/(?<=data:image.{65}).*?"/', '..."', $new); 155 | 156 | $diff = new \Diff($old, $new); 157 | 158 | return $diff->render(new \Diff_Renderer_Html_SideBySide()); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/OdinRoutes.php: -------------------------------------------------------------------------------- 1 | group([ 10 | 'prefix' => 'odin', 11 | 'as' => 'odin.', 12 | ], function () { 13 | app('router')->setGroupNamespace('\ctf0\Odin\Controllers'); 14 | 15 | app('router')->post('revision/{id}/preview', 'OdinController@preview')->name('preview'); 16 | app('router')->post('restore/{id}', 'OdinController@restore')->name('restore'); 17 | app('router')->put('restore-soft/{id}', 'OdinController@restoreSoft')->name('restore.soft'); 18 | app('router')->delete('remove/{id}', 'OdinController@remove')->name('remove'); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/OdinServiceProvider.php: -------------------------------------------------------------------------------- 1 | packagePublish(); 18 | $this->registerMacro(); 19 | $this->command(); 20 | } 21 | 22 | protected function registerMacro() 23 | { 24 | $this->app['router']->macro('setGroupNamespace', function ($namesapce = null) { 25 | $lastGroupStack = array_pop($this->groupStack); 26 | 27 | if ($lastGroupStack !== null) { 28 | Arr::set($lastGroupStack, 'namespace', $namesapce); 29 | $this->groupStack[] = $lastGroupStack; 30 | } 31 | 32 | return $this; 33 | }); 34 | } 35 | 36 | /** 37 | * [packagePublish description]. 38 | * 39 | * @return [type] [description] 40 | */ 41 | protected function packagePublish() 42 | { 43 | // migrations 44 | $this->publishes([ 45 | __DIR__ . '/database' => database_path('migrations'), 46 | ], 'migrations'); 47 | 48 | // resources 49 | $this->publishes([ 50 | __DIR__ . '/resources/assets' => resource_path('assets/vendor/Odin'), 51 | ], 'assets'); 52 | 53 | // trans 54 | $this->loadTranslationsFrom(__DIR__ . '/resources/lang', 'Odin'); 55 | $this->publishes([ 56 | __DIR__ . '/resources/lang' => resource_path('lang/vendor/Odin'), 57 | ], 'trans'); 58 | 59 | // views 60 | $this->loadViewsFrom(__DIR__ . '/resources/views', 'Odin'); 61 | $this->publishes([ 62 | __DIR__ . '/resources/views' => resource_path('views/vendor/Odin'), 63 | ], 'views'); 64 | } 65 | 66 | /** 67 | * package commands. 68 | * 69 | * @return [type] [description] 70 | */ 71 | protected function command() 72 | { 73 | $this->commands([ 74 | GarbageCollector::class, 75 | PackageSetup::class, 76 | ]); 77 | } 78 | 79 | /** 80 | * Register any package services. 81 | * 82 | * @return [type] [description] 83 | */ 84 | public function register() 85 | { 86 | $this->app->singleton('odin', function () { 87 | return new Odin(); 88 | }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Traits/Revisions.php: -------------------------------------------------------------------------------- 1 | savePivotAudit( 17 | 'Attached', 18 | $model->getKey(), 19 | get_class($model->$relationName()->getRelated()), 20 | $pivotIds[0], 21 | $model->updated_at 22 | ); 23 | } 24 | }); 25 | 26 | static::pivotDetached(function ($model, $relationName, $pivotIds) { 27 | if ($pivotIds) { 28 | return $model->savePivotAudit( 29 | 'Detached', 30 | $model->getKey(), 31 | get_class($model->$relationName()->getRelated()), 32 | $pivotIds[0], 33 | $model->updated_at 34 | ); 35 | } 36 | }); 37 | } 38 | 39 | private function savePivotAudit($eventName, $id, $relation, $pivotId, $date) 40 | { 41 | return app('db')->table('audits_pivot')->insert([ 42 | 'event' => $eventName, 43 | 'auditable_id' => $id, 44 | 'auditable_type' => $this->getMorphClass(), 45 | 'relation_id' => $pivotId, 46 | 'relation_type' => $relation, 47 | 'parent_updated_at'=> $date, 48 | ]); 49 | } 50 | 51 | private function getPivotAudits($type, $id, $date) 52 | { 53 | return app('db')->table('audits_pivot') 54 | ->where('auditable_id', $id) 55 | ->where('auditable_type', $type) 56 | ->where('parent_updated_at', $date) 57 | ->get(); 58 | } 59 | 60 | /** 61 | * override default. 62 | * 63 | * @return [type] [description] 64 | */ 65 | public function getAuditExclude(): array 66 | { 67 | $main = $this->auditExclude ?: []; 68 | $extra = ['user_id', 'id']; 69 | 70 | return array_merge($main, $extra); 71 | } 72 | 73 | /** 74 | * normal : $model->audits. 75 | */ 76 | public function getRevisionsAttribute() 77 | { 78 | return $this->audits->load('user')->reverse(); 79 | } 80 | 81 | /** 82 | * with relation : $model->auditsWithRelation. 83 | */ 84 | public function getRevisionsWithRelationAttribute() 85 | { 86 | return $this->audits->load('user')->map(function ($item) { 87 | $item['odin_relations'] = $this 88 | ->getPivotAudits($item->auditable_type, $item->auditable_id, $item->updated_at) 89 | ->groupBy(['parent_updated_at', 'relation_id', 'relation_type']) 90 | ->flatten(2) 91 | ->reject(function ($item) { 92 | return $item->count() == 2; 93 | })->flatten()->reverse()->groupBy(['relation_type']); 94 | 95 | return $item; 96 | })->reverse(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/database/2018_05_18_051821_create_audits_pivot_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->string('event'); 17 | $table->morphs('auditable'); 18 | $table->morphs('relation'); 19 | $table->timestamp('parent_updated_at'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down() 27 | { 28 | Schema::dropIfExists('audits_pivot'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/resources/assets/js/Odin.vue: -------------------------------------------------------------------------------- 1 | 170 | -------------------------------------------------------------------------------- /src/resources/assets/js/animated-scroll-to.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | // desiredOffset - page offset to scroll to 5 | // speed - duration of the scroll per 1000px 6 | function __ANIMATE_SCROLL_TO(desiredOffset) { 7 | var userOptions = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 8 | 9 | var options = { 10 | speed: 500, 11 | minDuration: 250, 12 | maxDuration: 1500, 13 | cancelOnUserAction: true, 14 | element: window, 15 | horizontal: false, 16 | onComplete: undefined, 17 | passive: true, 18 | offset: 0, 19 | useKeys: false 20 | }; 21 | 22 | var optionsKeys = Object.keys(options); 23 | 24 | // Override default options 25 | for (var i = 0; i < optionsKeys.length; i++) { 26 | var key = optionsKeys[i]; 27 | 28 | if (typeof userOptions[key] !== 'undefined') { 29 | options[key] = userOptions[key]; 30 | } 31 | } 32 | 33 | if (!options.cancelOnUserAction && options.passive) { 34 | options.passive = false; 35 | if (userOptions.passive) { 36 | console && console.warn( 37 | 'animated-scroll-to:\n "passive" was set to "false" to prevent errors, ' + 38 | 'as using "cancelOnUserAction: false" doesn\'t work with passive events.') 39 | } 40 | } 41 | 42 | if (desiredOffset instanceof HTMLElement) { 43 | if (userOptions.element && userOptions.element instanceof HTMLElement) { 44 | if (options.horizontal) { 45 | desiredOffset = (desiredOffset.getBoundingClientRect().left + userOptions.element.scrollLeft) 46 | - userOptions.element.getBoundingClientRect().left; 47 | } else { 48 | desiredOffset = (desiredOffset.getBoundingClientRect().top + userOptions.element.scrollTop) 49 | - userOptions.element.getBoundingClientRect().top; 50 | } 51 | } else if (options.horizontal) { 52 | var scrollLeft = window.scrollX || document.documentElement.scrollLeft; 53 | desiredOffset = scrollLeft + desiredOffset.getBoundingClientRect().left; 54 | } else { 55 | var scrollTop = window.scrollY || document.documentElement.scrollTop; 56 | desiredOffset = scrollTop + desiredOffset.getBoundingClientRect().top; 57 | } 58 | } 59 | 60 | // Add additonal user offset 61 | desiredOffset += options.offset 62 | 63 | options.isWindow = options.element === window; 64 | 65 | var initialScrollPosition = null; 66 | var initialAxisScollPosition = 0; 67 | var maxScroll = null; 68 | 69 | if (options.isWindow) { 70 | if (options.horizontal) { 71 | // get cross browser scroll positions 72 | initialScrollPosition = window.scrollX || document.documentElement.scrollLeft; 73 | initialAxisScollPosition = window.scrollY || document.documentElement.scrollTop; 74 | // cross browser document height minus window height 75 | maxScroll = Math.max( 76 | document.body.scrollWidth, document.documentElement.scrollWidth, 77 | document.body.offsetWidth, document.documentElement.offsetWidth, 78 | document.body.clientWidth, document.documentElement.clientWidth 79 | ) - window.innerWidth; 80 | } else { 81 | // get cross browser scroll positions 82 | initialScrollPosition = window.scrollY || document.documentElement.scrollTop; 83 | initialAxisScollPosition = window.scrollX || document.documentElement.scrollLeft; 84 | // cross browser document width minus window width 85 | maxScroll = Math.max( 86 | document.body.scrollHeight, document.documentElement.scrollHeight, 87 | document.body.offsetHeight, document.documentElement.offsetHeight, 88 | document.body.clientHeight, document.documentElement.clientHeight 89 | ) - window.innerHeight; 90 | } 91 | } else { 92 | // DOM element 93 | if (options.horizontal) { 94 | initialScrollPosition = options.element.scrollLeft; 95 | maxScroll = options.element.scrollWidth - options.element.clientWidth; 96 | } else { 97 | initialScrollPosition = options.element.scrollTop; 98 | maxScroll = options.element.scrollHeight - options.element.clientHeight; 99 | } 100 | } 101 | 102 | // If the scroll position is greater than maximum available scroll 103 | if (desiredOffset > maxScroll) { 104 | desiredOffset = maxScroll; 105 | } 106 | 107 | // Calculate diff to scroll 108 | var diff = desiredOffset - initialScrollPosition; 109 | 110 | // Do nothing if the page is already there 111 | if (diff === 0) { 112 | // Execute callback if there is any 113 | if (options.onComplete && typeof options.onComplete === 'function') { 114 | options.onComplete() 115 | } 116 | 117 | return; 118 | } 119 | 120 | // Calculate duration of the scroll 121 | var duration = Math.abs(Math.round((diff / 1000) * options.speed)); 122 | 123 | // Set minimum and maximum duration 124 | if (duration < options.minDuration) { 125 | duration = options.minDuration; 126 | } else if (duration > options.maxDuration) { 127 | duration = options.maxDuration; 128 | } 129 | 130 | var startingTime = Date.now(); 131 | 132 | // Request animation frame ID 133 | var requestID = null; 134 | 135 | // Method handler 136 | var handleUserEvent = null; 137 | var userEventOptions = { passive: options.passive }; 138 | 139 | if (options.cancelOnUserAction) { 140 | // Set handler to cancel scroll on user action 141 | handleUserEvent = function() { 142 | removeListeners(); 143 | cancelAnimationFrame(requestID); 144 | }; 145 | if (!options.useKeys) { 146 | window.addEventListener('keydown', handleUserEvent, userEventOptions); 147 | } 148 | window.addEventListener('mousedown', handleUserEvent, userEventOptions); 149 | } else { 150 | // Set handler to prevent user actions while scroll is active 151 | handleUserEvent = function(e) { e.preventDefault(); }; 152 | window.addEventListener('scroll', handleUserEvent, userEventOptions); 153 | } 154 | 155 | window.addEventListener('wheel', handleUserEvent, userEventOptions); 156 | window.addEventListener('touchstart', handleUserEvent, userEventOptions); 157 | 158 | var removeListeners = function () { 159 | window.removeEventListener('wheel', handleUserEvent, userEventOptions); 160 | window.removeEventListener('touchstart', handleUserEvent, userEventOptions); 161 | 162 | if (options.cancelOnUserAction) { 163 | if (!options.useKeys) { 164 | window.addEventListener('keydown', handleUserEvent, userEventOptions); 165 | } 166 | window.removeEventListener('mousedown', handleUserEvent, userEventOptions); 167 | } else { 168 | window.removeEventListener('scroll', handleUserEvent, userEventOptions); 169 | } 170 | }; 171 | 172 | var step = function () { 173 | var timeDiff = Date.now() - startingTime; 174 | var t = (timeDiff / duration) - 1; 175 | var easing = t * t * t + 1; 176 | var scrollPosition = Math.round(initialScrollPosition + (diff * easing)); 177 | 178 | var doScroll = function(position) { 179 | if (options.isWindow) { 180 | if (options.horizontal) { 181 | options.element.scrollTo(position, initialAxisScollPosition); 182 | } else { 183 | options.element.scrollTo(initialAxisScollPosition, position); 184 | } 185 | } else if (options.horizontal) { 186 | options.element.scrollLeft = position; 187 | } else { 188 | options.element.scrollTop = position; 189 | } 190 | } 191 | 192 | if (timeDiff < duration && scrollPosition !== desiredOffset) { 193 | // If scroll didn't reach desired offset or time is not elapsed 194 | // Scroll to a new position 195 | // And request a new step 196 | doScroll(scrollPosition); 197 | 198 | requestID = requestAnimationFrame(step); 199 | } else { 200 | // If the time elapsed or we reached the desired offset 201 | // Set scroll to the desired offset (when rounding made it to be off a pixel or two) 202 | // Clear animation frame to be sure 203 | doScroll(desiredOffset); 204 | 205 | cancelAnimationFrame(requestID); 206 | 207 | // Remove listeners 208 | removeListeners(); 209 | 210 | // Animation is complete, execute callback if there is any 211 | if (options.onComplete && typeof options.onComplete === 'function') { 212 | options.onComplete() 213 | } 214 | } 215 | }; 216 | 217 | // Start animating scroll 218 | requestID = requestAnimationFrame(step); 219 | } 220 | 221 | if (typeof exports !== 'undefined') { 222 | if (typeof module !== 'undefined' && module.exports) { 223 | module.exports = __ANIMATE_SCROLL_TO; 224 | exports = module.exports; 225 | } 226 | exports.default = __ANIMATE_SCROLL_TO; 227 | } else if (window) { 228 | window.animateScrollTo = __ANIMATE_SCROLL_TO; 229 | } 230 | }).call(this); 231 | -------------------------------------------------------------------------------- /src/resources/assets/js/manager.js: -------------------------------------------------------------------------------- 1 | /* Libs */ 2 | window.EventHub = require('vuemit') 3 | window.keycode = require('keycode') 4 | 5 | // axios 6 | window.axios = require('axios') 7 | axios.defaults.headers.common = { 8 | 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 9 | 'X-Requested-With': 'XMLHttpRequest' 10 | } 11 | axios.interceptors.response.use( 12 | (response) => response, 13 | (error) => Promise.reject(error.response) 14 | ) 15 | 16 | // vue-awesome 17 | import 'vue-awesome/icons/flag' 18 | Vue.component('icon', require('vue-awesome/components/Icon').default) 19 | 20 | /* Components */ 21 | Vue.component('Odin', require('./Odin.vue').default) 22 | Vue.component('MyNotification', require('vue-notif').default) 23 | 24 | /* Events */ 25 | EventHub.listen('odin-show', () => {}) 26 | EventHub.listen('odin-hide', () => {}) 27 | -------------------------------------------------------------------------------- /src/resources/assets/sass/packages/animate.scss: -------------------------------------------------------------------------------- 1 | .odin-animated { 2 | animation-duration: 600ms; 3 | animation-timing-function: cubic-bezier(1, 0, 0, 1); 4 | animation-fill-mode: both; 5 | } 6 | 7 | @keyframes fadeIn { 8 | from { 9 | opacity: 0; 10 | } 11 | 12 | to { 13 | opacity: 1; 14 | } 15 | } 16 | 17 | .fadeIn { 18 | animation-name: fadeIn; 19 | } 20 | 21 | @keyframes fadeInUp { 22 | from { 23 | transform: translate3d(0, 100%, 0); 24 | opacity: 0; 25 | } 26 | 27 | to { 28 | transform: none; 29 | opacity: 1; 30 | } 31 | } 32 | 33 | .fadeInUp { 34 | animation-name: fadeInUp; 35 | } 36 | -------------------------------------------------------------------------------- /src/resources/assets/sass/packages/bulma-timeline.css: -------------------------------------------------------------------------------- 1 | .timeline { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .timeline .timeline-header { 7 | min-width: 4em; 8 | word-wrap: normal; 9 | text-align: center; 10 | display: flex; 11 | justify-content: flex-start; 12 | } 13 | 14 | .timeline .timeline-item { 15 | display: flex; 16 | position: relative; 17 | margin-left: 2em; 18 | padding-bottom: 2em; 19 | } 20 | 21 | .timeline .timeline-item::before { 22 | content: ""; 23 | background-color: #dbdbdb; 24 | display: block; 25 | width: 0.1em; 26 | height: 100%; 27 | position: absolute; 28 | left: 0; 29 | top: 0; 30 | } 31 | 32 | .timeline .timeline-item .timeline-marker { 33 | position: absolute; 34 | background: #dbdbdb; 35 | border: 0.1em solid #dbdbdb; 36 | border-radius: 100%; 37 | content: ""; 38 | display: block; 39 | height: 0.8em; 40 | left: -0.35em; 41 | top: 1.2rem; 42 | width: 0.8em; 43 | } 44 | 45 | .timeline .timeline-item .timeline-marker.is-image { 46 | background: #dbdbdb; 47 | border: 0.1em solid #dbdbdb; 48 | border-radius: 100%; 49 | display: block; 50 | overflow: hidden; 51 | } 52 | 53 | .timeline .timeline-item .timeline-marker.is-image.is-16x16 { 54 | height: 16px; 55 | width: 16px; 56 | left: -8px; 57 | } 58 | 59 | .timeline .timeline-item .timeline-marker.is-image.is-24x24 { 60 | height: 24px; 61 | width: 24px; 62 | left: -12px; 63 | } 64 | 65 | .timeline .timeline-item .timeline-marker.is-image.is-32x32 { 66 | height: 32px; 67 | width: 32px; 68 | left: -16px; 69 | } 70 | 71 | .timeline .timeline-item .timeline-marker.is-image.is-48x48 { 72 | height: 48px; 73 | width: 48px; 74 | left: -24px; 75 | } 76 | 77 | .timeline .timeline-item .timeline-marker.is-image.is-64x64 { 78 | height: 64px; 79 | width: 64px; 80 | left: -32px; 81 | } 82 | 83 | .timeline .timeline-item .timeline-marker.is-image.is-96x96 { 84 | height: 96px; 85 | width: 96px; 86 | left: -48px; 87 | } 88 | 89 | .timeline .timeline-item .timeline-marker.is-image.is-128x128 { 90 | height: 128px; 91 | width: 128px; 92 | left: -64px; 93 | } 94 | 95 | .timeline .timeline-item .timeline-marker.is-icon { 96 | height: 1.5em; 97 | width: 1.5em; 98 | left: -0.7em; 99 | line-height: 0.75rem; 100 | padding: 0.25rem; 101 | background: #dbdbdb; 102 | border: 0.1em solid #dbdbdb; 103 | border-radius: 100%; 104 | color: white; 105 | } 106 | 107 | .timeline .timeline-item .timeline-marker.is-icon > i { 108 | color: white; 109 | font-size: 0.75rem !important; 110 | } 111 | 112 | .timeline .timeline-item .timeline-marker.is-outlined .image { 113 | background: white; 114 | } 115 | 116 | .timeline .timeline-item .timeline-marker.is-outlined.is-icon { 117 | background: white; 118 | } 119 | 120 | .timeline .timeline-item .timeline-marker.is-outlined.is-icon > i { 121 | color: #dbdbdb; 122 | } 123 | 124 | .timeline .timeline-item .timeline-marker.is-white { 125 | background-color: white !important; 126 | border-color: white !important; 127 | } 128 | 129 | .timeline .timeline-item .timeline-marker.is-white .image { 130 | border-color: white !important; 131 | } 132 | 133 | .timeline .timeline-item .timeline-marker.is-white.is-icon { 134 | background-color: white !important; 135 | border-color: white !important; 136 | } 137 | 138 | .timeline .timeline-item .timeline-marker.is-white.is-icon > i { 139 | color: #0a0a0a !important; 140 | } 141 | 142 | .timeline .timeline-item .timeline-marker.is-white.is-outlined { 143 | background-color: white !important; 144 | border-color: white !important; 145 | } 146 | 147 | .timeline .timeline-item .timeline-marker.is-white.is-outlined .image { 148 | background-color: white !important; 149 | } 150 | 151 | .timeline .timeline-item .timeline-marker.is-white.is-outlined.is-icon { 152 | background-color: white !important; 153 | } 154 | 155 | .timeline .timeline-item .timeline-marker.is-white.is-outlined.is-icon > i { 156 | color: white !important; 157 | } 158 | 159 | .timeline .timeline-item .timeline-marker.is-black { 160 | background-color: #0a0a0a !important; 161 | border-color: #0a0a0a !important; 162 | } 163 | 164 | .timeline .timeline-item .timeline-marker.is-black .image { 165 | border-color: #0a0a0a !important; 166 | } 167 | 168 | .timeline .timeline-item .timeline-marker.is-black.is-icon { 169 | background-color: #0a0a0a !important; 170 | border-color: #0a0a0a !important; 171 | } 172 | 173 | .timeline .timeline-item .timeline-marker.is-black.is-icon > i { 174 | color: white !important; 175 | } 176 | 177 | .timeline .timeline-item .timeline-marker.is-black.is-outlined { 178 | background-color: white !important; 179 | border-color: #0a0a0a !important; 180 | } 181 | 182 | .timeline .timeline-item .timeline-marker.is-black.is-outlined .image { 183 | background-color: white !important; 184 | } 185 | 186 | .timeline .timeline-item .timeline-marker.is-black.is-outlined.is-icon { 187 | background-color: white !important; 188 | } 189 | 190 | .timeline .timeline-item .timeline-marker.is-black.is-outlined.is-icon > i { 191 | color: #0a0a0a !important; 192 | } 193 | 194 | .timeline .timeline-item .timeline-marker.is-light { 195 | background-color: whitesmoke !important; 196 | border-color: whitesmoke !important; 197 | } 198 | 199 | .timeline .timeline-item .timeline-marker.is-light .image { 200 | border-color: whitesmoke !important; 201 | } 202 | 203 | .timeline .timeline-item .timeline-marker.is-light.is-icon { 204 | background-color: whitesmoke !important; 205 | border-color: whitesmoke !important; 206 | } 207 | 208 | .timeline .timeline-item .timeline-marker.is-light.is-icon > i { 209 | color: #363636 !important; 210 | } 211 | 212 | .timeline .timeline-item .timeline-marker.is-light.is-outlined { 213 | background-color: white !important; 214 | border-color: whitesmoke !important; 215 | } 216 | 217 | .timeline .timeline-item .timeline-marker.is-light.is-outlined .image { 218 | background-color: white !important; 219 | } 220 | 221 | .timeline .timeline-item .timeline-marker.is-light.is-outlined.is-icon { 222 | background-color: white !important; 223 | } 224 | 225 | .timeline .timeline-item .timeline-marker.is-light.is-outlined.is-icon > i { 226 | color: whitesmoke !important; 227 | } 228 | 229 | .timeline .timeline-item .timeline-marker.is-dark { 230 | background-color: #363636 !important; 231 | border-color: #363636 !important; 232 | } 233 | 234 | .timeline .timeline-item .timeline-marker.is-dark .image { 235 | border-color: #363636 !important; 236 | } 237 | 238 | .timeline .timeline-item .timeline-marker.is-dark.is-icon { 239 | background-color: #363636 !important; 240 | border-color: #363636 !important; 241 | } 242 | 243 | .timeline .timeline-item .timeline-marker.is-dark.is-icon > i { 244 | color: whitesmoke !important; 245 | } 246 | 247 | .timeline .timeline-item .timeline-marker.is-dark.is-outlined { 248 | background-color: white !important; 249 | border-color: #363636 !important; 250 | } 251 | 252 | .timeline .timeline-item .timeline-marker.is-dark.is-outlined .image { 253 | background-color: white !important; 254 | } 255 | 256 | .timeline .timeline-item .timeline-marker.is-dark.is-outlined.is-icon { 257 | background-color: white !important; 258 | } 259 | 260 | .timeline .timeline-item .timeline-marker.is-dark.is-outlined.is-icon > i { 261 | color: #363636 !important; 262 | } 263 | 264 | .timeline .timeline-item .timeline-marker.is-primary { 265 | background-color: #00d1b2 !important; 266 | border-color: #00d1b2 !important; 267 | } 268 | 269 | .timeline .timeline-item .timeline-marker.is-primary .image { 270 | border-color: #00d1b2 !important; 271 | } 272 | 273 | .timeline .timeline-item .timeline-marker.is-primary.is-icon { 274 | background-color: #00d1b2 !important; 275 | border-color: #00d1b2 !important; 276 | } 277 | 278 | .timeline .timeline-item .timeline-marker.is-primary.is-icon > i { 279 | color: #fff !important; 280 | } 281 | 282 | .timeline .timeline-item .timeline-marker.is-primary.is-outlined { 283 | background-color: white !important; 284 | border-color: #00d1b2 !important; 285 | } 286 | 287 | .timeline .timeline-item .timeline-marker.is-primary.is-outlined .image { 288 | background-color: white !important; 289 | } 290 | 291 | .timeline .timeline-item .timeline-marker.is-primary.is-outlined.is-icon { 292 | background-color: white !important; 293 | } 294 | 295 | .timeline .timeline-item .timeline-marker.is-primary.is-outlined.is-icon > i { 296 | color: #00d1b2 !important; 297 | } 298 | 299 | .timeline .timeline-item .timeline-marker.is-link { 300 | background-color: #3273dc !important; 301 | border-color: #3273dc !important; 302 | } 303 | 304 | .timeline .timeline-item .timeline-marker.is-link .image { 305 | border-color: #3273dc !important; 306 | } 307 | 308 | .timeline .timeline-item .timeline-marker.is-link.is-icon { 309 | background-color: #3273dc !important; 310 | border-color: #3273dc !important; 311 | } 312 | 313 | .timeline .timeline-item .timeline-marker.is-link.is-icon > i { 314 | color: #fff !important; 315 | } 316 | 317 | .timeline .timeline-item .timeline-marker.is-link.is-outlined { 318 | background-color: white !important; 319 | border-color: #3273dc !important; 320 | } 321 | 322 | .timeline .timeline-item .timeline-marker.is-link.is-outlined .image { 323 | background-color: white !important; 324 | } 325 | 326 | .timeline .timeline-item .timeline-marker.is-link.is-outlined.is-icon { 327 | background-color: white !important; 328 | } 329 | 330 | .timeline .timeline-item .timeline-marker.is-link.is-outlined.is-icon > i { 331 | color: #3273dc !important; 332 | } 333 | 334 | .timeline .timeline-item .timeline-marker.is-info { 335 | background-color: #209cee !important; 336 | border-color: #209cee !important; 337 | } 338 | 339 | .timeline .timeline-item .timeline-marker.is-info .image { 340 | border-color: #209cee !important; 341 | } 342 | 343 | .timeline .timeline-item .timeline-marker.is-info.is-icon { 344 | background-color: #209cee !important; 345 | border-color: #209cee !important; 346 | } 347 | 348 | .timeline .timeline-item .timeline-marker.is-info.is-icon > i { 349 | color: #fff !important; 350 | } 351 | 352 | .timeline .timeline-item .timeline-marker.is-info.is-outlined { 353 | background-color: white !important; 354 | border-color: #209cee !important; 355 | } 356 | 357 | .timeline .timeline-item .timeline-marker.is-info.is-outlined .image { 358 | background-color: white !important; 359 | } 360 | 361 | .timeline .timeline-item .timeline-marker.is-info.is-outlined.is-icon { 362 | background-color: white !important; 363 | } 364 | 365 | .timeline .timeline-item .timeline-marker.is-info.is-outlined.is-icon > i { 366 | color: #209cee !important; 367 | } 368 | 369 | .timeline .timeline-item .timeline-marker.is-success { 370 | background-color: #23d160 !important; 371 | border-color: #23d160 !important; 372 | } 373 | 374 | .timeline .timeline-item .timeline-marker.is-success .image { 375 | border-color: #23d160 !important; 376 | } 377 | 378 | .timeline .timeline-item .timeline-marker.is-success.is-icon { 379 | background-color: #23d160 !important; 380 | border-color: #23d160 !important; 381 | } 382 | 383 | .timeline .timeline-item .timeline-marker.is-success.is-icon > i { 384 | color: #fff !important; 385 | } 386 | 387 | .timeline .timeline-item .timeline-marker.is-success.is-outlined { 388 | background-color: white !important; 389 | border-color: #23d160 !important; 390 | } 391 | 392 | .timeline .timeline-item .timeline-marker.is-success.is-outlined .image { 393 | background-color: white !important; 394 | } 395 | 396 | .timeline .timeline-item .timeline-marker.is-success.is-outlined.is-icon { 397 | background-color: white !important; 398 | } 399 | 400 | .timeline .timeline-item .timeline-marker.is-success.is-outlined.is-icon > i { 401 | color: #23d160 !important; 402 | } 403 | 404 | .timeline .timeline-item .timeline-marker.is-warning { 405 | background-color: #ffdd57 !important; 406 | border-color: #ffdd57 !important; 407 | } 408 | 409 | .timeline .timeline-item .timeline-marker.is-warning .image { 410 | border-color: #ffdd57 !important; 411 | } 412 | 413 | .timeline .timeline-item .timeline-marker.is-warning.is-icon { 414 | background-color: #ffdd57 !important; 415 | border-color: #ffdd57 !important; 416 | } 417 | 418 | .timeline .timeline-item .timeline-marker.is-warning.is-icon > i { 419 | color: rgba(0, 0, 0, 0.7) !important; 420 | } 421 | 422 | .timeline .timeline-item .timeline-marker.is-warning.is-outlined { 423 | background-color: white !important; 424 | border-color: #ffdd57 !important; 425 | } 426 | 427 | .timeline .timeline-item .timeline-marker.is-warning.is-outlined .image { 428 | background-color: white !important; 429 | } 430 | 431 | .timeline .timeline-item .timeline-marker.is-warning.is-outlined.is-icon { 432 | background-color: white !important; 433 | } 434 | 435 | .timeline .timeline-item .timeline-marker.is-warning.is-outlined.is-icon > i { 436 | color: #ffdd57 !important; 437 | } 438 | 439 | .timeline .timeline-item .timeline-marker.is-danger { 440 | background-color: #ff3860 !important; 441 | border-color: #ff3860 !important; 442 | } 443 | 444 | .timeline .timeline-item .timeline-marker.is-danger .image { 445 | border-color: #ff3860 !important; 446 | } 447 | 448 | .timeline .timeline-item .timeline-marker.is-danger.is-icon { 449 | background-color: #ff3860 !important; 450 | border-color: #ff3860 !important; 451 | } 452 | 453 | .timeline .timeline-item .timeline-marker.is-danger.is-icon > i { 454 | color: #fff !important; 455 | } 456 | 457 | .timeline .timeline-item .timeline-marker.is-danger.is-outlined { 458 | background-color: white !important; 459 | border-color: #ff3860 !important; 460 | } 461 | 462 | .timeline .timeline-item .timeline-marker.is-danger.is-outlined .image { 463 | background-color: white !important; 464 | } 465 | 466 | .timeline .timeline-item .timeline-marker.is-danger.is-outlined.is-icon { 467 | background-color: white !important; 468 | } 469 | 470 | .timeline .timeline-item .timeline-marker.is-danger.is-outlined.is-icon > i { 471 | color: #ff3860 !important; 472 | } 473 | 474 | .timeline .timeline-item .timeline-content { 475 | padding: 1em 0 0 0.5em; 476 | padding: 1em 0 0 2em; 477 | } 478 | 479 | .timeline .timeline-item .timeline-content .heading { 480 | font-weight: 600; 481 | } 482 | 483 | .timeline .timeline-item.is-white::before { 484 | background-color: white; 485 | } 486 | 487 | .timeline .timeline-item.is-black::before { 488 | background-color: #0a0a0a; 489 | } 490 | 491 | .timeline .timeline-item.is-light::before { 492 | background-color: whitesmoke; 493 | } 494 | 495 | .timeline .timeline-item.is-dark::before { 496 | background-color: #363636; 497 | } 498 | 499 | .timeline .timeline-item.is-primary::before { 500 | background-color: #00d1b2; 501 | } 502 | 503 | .timeline .timeline-item.is-link::before { 504 | background-color: #3273dc; 505 | } 506 | 507 | .timeline .timeline-item.is-info::before { 508 | background-color: #209cee; 509 | } 510 | 511 | .timeline .timeline-item.is-success::before { 512 | background-color: #23d160; 513 | } 514 | 515 | .timeline .timeline-item.is-warning::before { 516 | background-color: #ffdd57; 517 | } 518 | 519 | .timeline .timeline-item.is-danger::before { 520 | background-color: #ff3860; 521 | } 522 | 523 | .timeline.is-centered .timeline-header { 524 | display: flex; 525 | width: 100%; 526 | align-self: center; 527 | } 528 | 529 | .timeline.is-centered .timeline-item { 530 | width: 50%; 531 | align-self: flex-end; 532 | } 533 | 534 | .timeline.is-centered .timeline-item:nth-of-type(2n) { 535 | align-self: flex-start; 536 | margin-left: 0; 537 | margin-right: 2em; 538 | } 539 | 540 | .timeline.is-centered .timeline-item:nth-of-type(2n)::before { 541 | right: -0.1em; 542 | left: auto; 543 | } 544 | 545 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker { 546 | left: auto; 547 | right: -0.45em; 548 | } 549 | 550 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-image.is-16x16 { 551 | left: auto; 552 | right: -8px; 553 | } 554 | 555 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-image.is-24x24 { 556 | left: auto; 557 | right: -12px; 558 | } 559 | 560 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-image.is-32x32 { 561 | left: auto; 562 | right: -16px; 563 | } 564 | 565 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-image.is-48x48 { 566 | left: auto; 567 | right: -24px; 568 | } 569 | 570 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-image.is-64x64 { 571 | left: auto; 572 | right: -32px; 573 | } 574 | 575 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-image.is-96x96 { 576 | left: auto; 577 | right: -48px; 578 | } 579 | 580 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-image.is-128x128 { 581 | left: auto; 582 | right: -64px; 583 | } 584 | 585 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-marker.is-icon { 586 | left: auto; 587 | right: -0.8em; 588 | } 589 | 590 | .timeline.is-centered .timeline-item:nth-of-type(2n) .timeline-content { 591 | padding: 1em 2em 0 0; 592 | text-align: right; 593 | display: flex; 594 | flex-direction: column; 595 | align-items: flex-end; 596 | flex-basis: 100%; 597 | } 598 | 599 | .timeline.is-centered .timeline-item:nth-of-type(2n+1)::before { 600 | content: ""; 601 | background-color: #dbdbdb; 602 | display: block; 603 | width: 0.1em; 604 | height: 100%; 605 | position: absolute; 606 | top: 0; 607 | } 608 | 609 | .timeline.is-rtl { 610 | justify-content: flex-end; 611 | align-items: flex-end; 612 | } 613 | 614 | .timeline.is-rtl .timeline-item { 615 | justify-content: flex-end; 616 | border-left: none; 617 | margin-left: 0; 618 | margin-right: 2em; 619 | } 620 | 621 | .timeline.is-rtl .timeline-item::before { 622 | right: 0; 623 | left: auto; 624 | } 625 | 626 | .timeline.is-rtl .timeline-item .timeline-marker { 627 | left: auto; 628 | right: -0.35em; 629 | } 630 | 631 | .timeline.is-rtl .timeline-item .timeline-marker.is-image.is-16x16 { 632 | left: auto; 633 | right: -8px; 634 | } 635 | 636 | .timeline.is-rtl .timeline-item .timeline-marker.is-image.is-24x24 { 637 | left: auto; 638 | right: -12px; 639 | } 640 | 641 | .timeline.is-rtl .timeline-item .timeline-marker.is-image.is-32x32 { 642 | left: auto; 643 | right: -16px; 644 | } 645 | 646 | .timeline.is-rtl .timeline-item .timeline-marker.is-image.is-48x48 { 647 | left: auto; 648 | right: -24px; 649 | } 650 | 651 | .timeline.is-rtl .timeline-item .timeline-marker.is-image.is-64x64 { 652 | left: auto; 653 | right: -32px; 654 | } 655 | 656 | .timeline.is-rtl .timeline-item .timeline-marker.is-image.is-96x96 { 657 | left: auto; 658 | right: -48px; 659 | } 660 | 661 | .timeline.is-rtl .timeline-item .timeline-marker.is-image.is-128x128 { 662 | left: auto; 663 | right: -64px; 664 | } 665 | 666 | .timeline.is-rtl .timeline-item .timeline-marker.is-icon { 667 | left: auto; 668 | right: -0.7em; 669 | } 670 | 671 | .timeline.is-rtl .timeline-item .timeline-content { 672 | padding: 1em 2em 0 0; 673 | text-align: right; 674 | } 675 | -------------------------------------------------------------------------------- /src/resources/assets/sass/style.scss: -------------------------------------------------------------------------------- 1 | @import './packages/animate'; 2 | @import './packages/bulma-timeline.css'; 3 | 4 | // overlay 5 | .shade { 6 | position: fixed; 7 | z-index: 6; 8 | top: 0; 9 | right: 0; 10 | bottom: 0; 11 | left: 0; 12 | background-color: rgba(black, 0.9); 13 | } 14 | 15 | // revisions list 16 | .revisions { 17 | margin-top: 50px; 18 | padding-top: 1rem; 19 | border-top: 3px dashed whitesmoke; 20 | 21 | .revisions-link { 22 | cursor: pointer; 23 | 24 | td { 25 | border: none !important; 26 | } 27 | } 28 | } 29 | 30 | #scrollArea { 31 | display: block; 32 | overflow: scroll; 33 | height: 100%; 34 | } 35 | 36 | // diff 37 | .compare-page { 38 | position: fixed; 39 | z-index: 6; 40 | top: 0; 41 | right: 0; 42 | bottom: 0; 43 | overflow: scroll; 44 | width: 60%; 45 | height: 100%; 46 | padding: 2rem; 47 | background-color: white; 48 | 49 | .timeline-content { 50 | min-width: 100%; 51 | } 52 | } 53 | 54 | @media screen and (max-width: 1023px) { 55 | .compare-page { 56 | width: 100%; 57 | } 58 | } 59 | 60 | .compare-page__body { 61 | padding-top: 10px; 62 | 63 | .table { 64 | background-color: white; 65 | } 66 | 67 | .title { 68 | margin: 0; 69 | margin-left: 10px; 70 | } 71 | 72 | .crumbs { 73 | position: relative; 74 | 75 | &::before, 76 | &::after { 77 | position: absolute; 78 | top: 15px; 79 | left: -38px; 80 | content: ''; 81 | } 82 | 83 | &::before { 84 | height: 25px; 85 | } 86 | 87 | &::after { 88 | width: 34px; 89 | border-top: 0.1rem dashed #dbdbdb; 90 | } 91 | 92 | span { 93 | position: relative; 94 | z-index: 1; 95 | color: #363636; 96 | 97 | &::before { 98 | font-size: 2.1rem; 99 | position: absolute; 100 | top: 2px; 101 | left: -14px; 102 | content: '•'; 103 | } 104 | } 105 | 106 | &.is-5 { 107 | margin-left: 22px; 108 | color: #dbdbdb; 109 | 110 | &::before, 111 | &::after { 112 | top: 10px; 113 | left: -50px; 114 | } 115 | 116 | &::before { 117 | height: 90px; 118 | } 119 | 120 | &::after { 121 | width: 44px; 122 | } 123 | 124 | span { 125 | color: #dbdbdb; 126 | 127 | &::before { 128 | top: -4px; 129 | left: -16px; 130 | } 131 | } 132 | } 133 | } 134 | 135 | .Differences { 136 | border-spacing: 10px; 137 | border-collapse: separate; 138 | 139 | td { 140 | width: 50%; 141 | padding: 1rem !important; 142 | vertical-align: top; 143 | } 144 | 145 | ins, 146 | del { 147 | &:not(:empty) { 148 | font-weight: bold; 149 | padding: 2px 4px; 150 | text-decoration: none; 151 | color: white; 152 | } 153 | } 154 | 155 | .Skipped { 156 | background: #f7f7f7; 157 | } 158 | } 159 | 160 | .DifferencesSideBySide { 161 | .ChangeInsert td { 162 | &.Left, 163 | &.Right { 164 | background: rgba(0, 209, 178, 0.1); 165 | } 166 | } 167 | 168 | .ChangeDelete td { 169 | &.Left, 170 | &.Right { 171 | background: rgba(255, 56, 96, 0.1); 172 | } 173 | } 174 | 175 | .ChangeReplace { 176 | .Left { 177 | background: rgba(255, 56, 96, 0.1); 178 | } 179 | 180 | del { 181 | background: #ff3860; 182 | } 183 | 184 | .Right { 185 | background: rgba(0, 209, 178, 0.1); 186 | } 187 | 188 | ins { 189 | background: #00d1b2; 190 | } 191 | } 192 | } 193 | } 194 | 195 | .compare-page__footer { 196 | margin-top: 3rem; 197 | padding-top: 1.5rem; 198 | border-top: 1px solid #dbdbdb; 199 | } 200 | 201 | .no-scroll { 202 | overflow: hidden; 203 | } 204 | -------------------------------------------------------------------------------- /src/resources/lang/en/messages.php: -------------------------------------------------------------------------------- 1 | 'Ajax Call Failed', 5 | 'cant_preview' => 'Events of Type (\'created\', \'restored\') Can\'t Be Previewed.', 6 | 'cant_restore' => 'Events of Type (\'deleted\', \'restored\') Can\'t Be Restored.', 7 | 'cant_soft_restore' => 'Only Events of Type (\'deleted\') Can Be Restored.', 8 | 'del' => 'Remove Revision', 9 | 'del_success' => 'Revision Removed !', 10 | 'no_diff' => 'N o - C h a n g e s - M a d e', 11 | 'preview' => 'Preview', 12 | 'res' => 'Restore Revision', 13 | 'res_model' => 'Restore Model', 14 | 'res_model_success' => 'Model Restored !', 15 | 'res_success' => 'Revision Restored !', 16 | 'reset_data' => '* If In Doubt, You Can Always Use This Revision To Reset Data Back To Its Initial State.', 17 | 'went_bad' => 'Something Went Wrong !', 18 | 'by' => 'By', 19 | ]; 20 | -------------------------------------------------------------------------------- /src/resources/views/list.blade.php: -------------------------------------------------------------------------------- 1 | {{-- styles --}} 2 | 3 | 4 | 5 |
6 | {{-- notifications --}} 7 |
8 | @if (session('status')) 9 | 14 | 15 | @endif 16 | 17 | 18 |
19 | 20 | {{-- Odin --}} 21 | 24 | 25 |
26 | {{-- overlay --}} 27 |
28 | 29 |
30 |
31 | {{-- list --}} 32 | 33 | 34 | @foreach($revisions as $rev) 35 | @php 36 | $html = app('odin')->toHtml($rev); 37 | @endphp 38 | 39 | @if($html || $rev->odin_relations->count()) 40 | 43 | 44 | {{-- user avatar --}} 45 | 50 | 51 | {{-- user name --}} 52 | 53 | 54 | {{-- date --}} 55 | 59 | 60 | @endif 61 | @endforeach 62 | 63 |
64 | 65 | {{-- diff --}} 66 |
67 | 68 | {{-- close --}} 69 |
70 | 71 |
72 | 73 | {{-- content --}} 74 |
    75 | @foreach($revisions as $rev) 76 | @php 77 | $html = app('odin')->toHtml($rev); 78 | $class = $rev->event == 'created' ? 'is-link is-outlined' : 'is-success'; 79 | $previewCheck = isset($template) && !in_array($rev->event, ['created', 'restored']); 80 | @endphp 81 | 82 | @if($html || $rev->odin_relations->count()) 83 | {{-- date --}} 84 |
  • 85 | 89 |
  • 90 | 91 | {{-- data --}} 92 |
  • 93 |
    95 | 98 |
    99 | 100 |
    101 |
    102 | {{-- event name --}} 103 |

    {{ $rev->event }}

    104 | 105 | {{-- event date --}} 106 |

    107 | 108 | {{ $rev->created_at->format('F j, Y @ h:i:s A') }} 109 | 110 |

    111 | 112 | {{-- event user --}} 113 |

    114 | {{ trans('Odin::messages.by') }} 115 | {{ $rev->user->name ?? '' }} 116 |

    117 |
    118 | 119 |
    120 |
    121 | {{-- body --}} 122 | @if($html) 123 | {!! $html !!} 124 | @endif 125 | 126 | {{-- relations --}} 127 | @if($rev->odin_relations->count()) 128 | @foreach($rev->odin_relations as $key => $val) 129 |

    {{ class_basename($key) }}

    130 | 131 | 132 | @foreach($val as $item) 133 | 134 | 135 | 136 | 137 | @endforeach 138 | 139 |
    {{ $item->event == 'Detached' ? app($key)->find($item->relation_id)->misc_title : '' }}{{ $item->event == 'Attached' ? app($key)->find($item->relation_id)->misc_title : '' }}
    140 | @endforeach 141 | @endif 142 |
    143 | 144 | {{-- ops --}} 145 | @if(count($revisions) > 1) 146 | 222 | @endif 223 |
    224 |
    225 |
  • 226 | @endif 227 | @endforeach 228 |
229 |
230 | 231 |
232 |
233 |
234 |
235 | 236 |
237 | 238 | {{-- Footer --}} 239 | 240 | --------------------------------------------------------------------------------