├── .browserslistrc ├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation-improvement.md │ └── feature_request.md ├── .gitignore ├── .postcssrc ├── .styleci.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dashboard.css ├── Dashboard.js ├── Dashboard.module ├── DashboardPanel.class.php ├── DashboardPanelAddNew.css ├── DashboardPanelAddNew.module ├── DashboardPanelChart.css ├── DashboardPanelChart.js ├── DashboardPanelChart.module ├── DashboardPanelCollection.css ├── DashboardPanelCollection.js ├── DashboardPanelCollection.module ├── DashboardPanelHelloWorld.css ├── DashboardPanelHelloWorld.js ├── DashboardPanelHelloWorld.module ├── DashboardPanelInstance.class.php ├── DashboardPanelNotice.css ├── DashboardPanelNotice.module ├── DashboardPanelNumber.css ├── DashboardPanelNumber.module ├── DashboardPanelPageList.css ├── DashboardPanelPageList.js ├── DashboardPanelPageList.module ├── DashboardPanelShortcuts.css ├── DashboardPanelShortcuts.module ├── DashboardPanelTemplate.module ├── LICENSE ├── README.md ├── VERSION ├── composer.json ├── composer.lock ├── docs ├── .nojekyll ├── _sidebar.md ├── assets │ └── prism-themes │ │ └── prismjs-github.css ├── configuration.md ├── getting-started.md ├── images │ ├── add-new-dropdown.png │ ├── add-new.png │ ├── chart.png │ ├── collection.png │ ├── dashboard.png │ ├── groups.png │ ├── notice.png │ ├── number.png │ ├── page-list.png │ ├── shortcuts-comparison.png │ ├── shortcuts-grid.png │ └── template.png ├── index.html ├── introduction.md ├── license.md ├── panels.md ├── panels │ ├── add-new.md │ ├── chart.md │ ├── collection.md │ ├── custom.md │ ├── groups.md │ ├── notice.md │ ├── number.md │ ├── page-list.md │ ├── shortcuts.md │ ├── tabs.md │ ├── template.md │ └── third-party.md └── requirements.md ├── package-lock.json ├── package.json ├── rector.php ├── src ├── Dashboard.css ├── Dashboard.js ├── DashboardPanelAddNew.css ├── DashboardPanelChart.css ├── DashboardPanelChart.js ├── DashboardPanelCollection.css ├── DashboardPanelCollection.js ├── DashboardPanelHelloWorld.css ├── DashboardPanelHelloWorld.js ├── DashboardPanelNotice.css ├── DashboardPanelNumber.css ├── DashboardPanelPageList.css ├── DashboardPanelPageList.js ├── DashboardPanelShortcuts.css ├── charts │ ├── chartjs-defaults.js │ └── color-themes.js └── lib │ └── tooltips.js └── views ├── dashboard.php ├── group.php ├── panel.php ├── panels.php └── panels ├── chart.php └── number.php /.browserslistrc: -------------------------------------------------------------------------------- 1 | >0.5% 2 | last 3 major versions 3 | last 1 IE versions 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | $ cat .gitattributes 2 | *.module linguist-language=PHP 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Context** 23 | For server-side issues, include the relevant information: 24 | - PHP version [e.g. 7.3] 25 | - ProcessWire version [e.g. 3.0.148] 26 | 27 | For browser display issues, include the relevant information: 28 | - OS: [e.g. Win 10, MacOS 10.15] 29 | - Browser & Version [e.g. Chrome 79, Safari 13.0] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation improvement 3 | about: Correct or request documentation 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What features are you missing documentation for?** 11 | Describe any functionality that isn't included in the docs. Use code examples where possible. 12 | 13 | **Which parts of the documentation are wrong or incomplete?** 14 | Describe what is incorrect, confusing or out of date. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Vendor files 2 | node_modules 3 | vendor 4 | 5 | # System files 6 | .DS_STORE 7 | Thumbs.db 8 | config.rb 9 | 10 | # IDE 11 | /.vscode/tasks.json 12 | 13 | # Pre- and post-processing 14 | .cache 15 | .parcel-cache 16 | .sass-cache 17 | *.scssc 18 | *.sassc 19 | -------------------------------------------------------------------------------- /.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "postcss-nested": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | finder: 2 | name: 3 | - "*.php" 4 | - "*.module" 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.css": "postcss" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.5.9] - 2025-05-04 4 | 5 | - Ignore ajax requests that do not render a panel 6 | - Bump tooling dependencies 7 | 8 | ## [1.5.8] - 2025-01-14 9 | 10 | - Fix warning when rendering parent in collection panel 11 | 12 | ## [1.5.7] - 2024-12-18 13 | 14 | - Avoid rendering empty attributes 15 | 16 | ## [1.5.6] - 2024-07-22 17 | 18 | - Improve responsive tab rendering 19 | 20 | ## [1.5.5] - 2024-02-28 21 | 22 | - Fix deprecation warning 23 | 24 | ## [1.5.4] - 2023-10-20 25 | 26 | - Update dependencies 27 | 28 | ## [1.5.3] - 2023-03-18 29 | 30 | - Fix PHP 8.2 deprecation warning by allowing dynamic properties (@adrianbj) 31 | 32 | ## [1.5.2] - 2022-09-20 33 | 34 | - Fix notice when adding shortcut URLs as strings 35 | 36 | ## [1.5.1] - 2022-09-03 37 | 38 | - Confirm before trashing pages in collection panel 39 | - Avoid overflowing shortcut titles 40 | - Add default color variables for third-party plugins 41 | 42 | ## [1.5.0] - 2022-09-02 43 | 44 | - Add new panel size micro (@netcarver) 45 | - Allow publishing & hiding pages in collection panel by using special column names 46 | 47 | ## [1.4.2] - 2022-05-20 48 | 49 | - Fix invisible icons by checking for supported icons 50 | 51 | ## [1.4.1] - 2022-05-18 52 | 53 | - Fix invisible icons in collection panel 54 | 55 | ## [1.4.0] - 2022-05-15 56 | 57 | - Display pagination links in collection panel 58 | 59 | ## [1.3.0] - 2022-05-13 60 | 61 | - Allow setting browser title separately from headline 62 | - Display optional empty-result placeholder in collection panel 63 | - Add ability to trash pages from collection panel 64 | - Allow custom order of actions in collection panel 65 | - Open external links in shortcut panel in new tab 66 | - Allow passing post params when reloading panels 67 | 68 | ## [1.2.1] - 2022-01-30 69 | 70 | - Display details in empty number panels 71 | 72 | ## [1.2.0] - 2021-10-14 73 | 74 | - Allow grouping panels in tabs 75 | - Make add-new button fill available width 76 | 77 | ## [1.1.1] - 2021-09-24 78 | 79 | - Verify ajax requests to avoid errors with Tracy debug bar 80 | 81 | ## [1.1.0] - 2021-09-12 82 | 83 | - Use CSS variables to allow style customization 84 | - Update to parcel v2 for asset bundling 85 | - Fix error when parsing server locale for number formatting 86 | 87 | ## [1.0.3] - 2021-01-13 88 | 89 | - Improve compatibility on multisite installations 90 | - Fix overflowing panel content 91 | - Fix stylesheets not getting picked up 92 | 93 | ## [1.0.2] - 2020-12-20 94 | 95 | - Fix outdated module version 96 | 97 | ## [1.0.1] - 2020-12-17 98 | 99 | - Fix notice for missing trend parameter 100 | 101 | ## [1.0.0] - 2020-12-13 102 | 103 | - **Require minimum PW version 3.0.165** 104 | - Add new panel type: Add New Page 105 | - Move module into private namespace 106 | 107 | ## [0.7.3] - 2020-08-07 108 | 109 | - PageList panel: allow adding new pages in modal 110 | - Fix clipped drop shadow of panels nested in groups 111 | 112 | ## [0.7.2] - 2020-08-07 113 | 114 | - Fix error when using markup strings in collection field names 115 | 116 | ## [0.7.1] - 2020-08-05 117 | 118 | - Remove unintended session message in collections panel 119 | 120 | ## [0.7.0] - 2020-08-02 121 | 122 | - Set sane table header defaults in collection panels 123 | - Add option to hide shortcut summaries 124 | - Fix undefined indexes when destructuring shortcuts 125 | 126 | ## [0.6.9] - 2020-01-31 127 | 128 | - Fix missing version number in module directory 129 | - Improve documentation of panel parameters 130 | - Code quality improvements 131 | 132 | ## [0.6.8] - 2020-01-20 133 | 134 | - Enable module installation via ZIP download in admin interface 135 | 136 | ## [0.6.7] - 2020-01-18 137 | 138 | - Allow setting HTML attributes of panel elements 139 | - Set `data-file` attribute on `template` panels to simplify CSS namespacing 140 | - Auto-include associated CSS & JS files of `template` panels 141 | - Allow event handlers to cancel panel reload 142 | - Intercept `chart` panel reloads and update canvas manually 143 | 144 | ## [0.6.6] - 2020-01-17 145 | 146 | - Fix error in case of undefined Uikit helper 147 | - Fix notice in case of undefined URL parts 148 | - Re-enable default theme tooltips 149 | - Fix multi-language display of links 150 | - Use DOM events to trigger panel reloads 151 | 152 | ## [0.6.5] - 2020-01-15 153 | 154 | - Add dashboard page to user dropdown navigation 155 | 156 | ## [0.6.4] - 2020-01-15 157 | 158 | - Support multiple `page-list` panels per dashboard 159 | - Add option to open `page-list` links in modal or new tab 160 | 161 | ## [0.6.3] - 2020-01-15 162 | 163 | - Simplify overwriting the default panel size 164 | - Add CSS & JS to hello-world module implementation 165 | - Fix missing modal scripts 166 | 167 | ## [0.6.2] - 2020-01-14 168 | 169 | - Allow adding multiple items at once via `$panels->add()` 170 | 171 | ## [0.6.1] - 2020-01-14 172 | 173 | - Allow importing multiple items at once via `$panels->import()` 174 | 175 | ## [0.6.0] - 2020-01-14 176 | 177 | - Animate panel refresh 178 | - Re-init tooltips after panel refresh 179 | - Add support for modals in collection panels 180 | 181 | ## [0.5.1] - 2020-01-14 182 | 183 | - Fix closing canvas tag 184 | - Use compass as module icon 185 | 186 | ## [0.5.0] - 2020-01-10 187 | 188 | - Implement AJAX auto-refresh for panels 189 | - Set default chart color theme via hook 190 | 191 | ## [0.4.14] - 2020-01-08 192 | 193 | - Use asset bundler to transpile and minify JS & CSS 194 | - Improve horizontal padding of PageList panel 195 | 196 | ## [0.4.13] - 2020-01-06 197 | 198 | - Fix color column output formatting 199 | - Disable word wrap in action column 200 | - Allow disabling access checks in shortcuts panel 201 | - Implement fallback number formatter 202 | 203 | ## [0.4.12] - 2020-01-06 204 | 205 | - Allow setting vertical alignment per panel 206 | - Add helpers for rendering footer buttons 207 | 208 | Chart panel improvements: 209 | 210 | - Color themes 211 | - Default styles for donut charts 212 | - Aspect ratio placeholder while chart is loading 213 | 214 | ## [0.4.11] - 2020-01-06 215 | 216 | - Display shortcut summaries as tooltips 217 | - Add list view for shortcuts panel 218 | 219 | ## [0.4.10] - 2020-01-06 220 | 221 | - Allow custom icons per shortcut 222 | - Hide sort buttons in actions column 223 | - Improve table display 224 | - Make default panel size hookable 225 | 226 | ## [0.4.9] - 2020-01-04 227 | 228 | - Add PHP & ProcessWire version constraints 229 | - Add create/view buttons to collection panel 230 | - Add support for color picker columns in collection panel 231 | - Add panel size: mini (one quarter) 232 | - Fix empty user label 233 | 234 | ## [0.4.8] - 2020-01-04 235 | 236 | - Improve table and page list display in default admin theme 237 | - Fix trailing comma when username is empty 238 | - Support pages and selectors as page list parent 239 | 240 | ## [0.4.7] - 2020-01-04 241 | 242 | - Enable display of panel icons 243 | 244 | ## [0.4.6] - 2020-01-03 245 | 246 | - Remember bumping versions 247 | 248 | ## [0.4.5] - 2020-01-03 249 | 250 | - Support image columns in collection panel 251 | - Support icons as collection column headers 252 | 253 | ## [0.4.4] - 2020-01-03 254 | 255 | - Fix regression introduced in previous release 256 | 257 | ## [0.4.3] - 2020-01-03 258 | 259 | - Fix assets auto-loading in frontend 260 | 261 | ## [0.4.2] - 2020-01-03 262 | 263 | - Add support for AdminThemeDefault 264 | - Add code examples in readme 265 | - Allow URLs in shortcut list 266 | 267 | ## [0.4.1] - 2020-01-03 268 | 269 | - AdminThemeReno support 270 | - Set chart library defaults 271 | - Make chart data manipulation hookable 272 | 273 | ## [0.4.0] - 2020-01-03 274 | 275 | - Allow nesting of panels in groups 276 | 277 | ## [0.3.0] - 2020-01-02 278 | 279 | - Initial public release 280 | 281 | [1.5.9]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.9 282 | [1.5.8]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.8 283 | [1.5.7]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.7 284 | [1.5.6]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.6 285 | [1.5.5]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.5 286 | [1.5.4]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.4 287 | [1.5.3]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.3 288 | [1.5.2]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.2 289 | [1.5.1]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.1 290 | [1.5.0]: https://github.com/daun/processwire-dashboard/releases/tag/v1.5.0 291 | [1.4.2]: https://github.com/daun/processwire-dashboard/releases/tag/v1.4.2 292 | [1.4.1]: https://github.com/daun/processwire-dashboard/releases/tag/v1.4.1 293 | [1.4.0]: https://github.com/daun/processwire-dashboard/releases/tag/v1.4.0 294 | [1.3.0]: https://github.com/daun/processwire-dashboard/releases/tag/v1.3.0 295 | [1.2.1]: https://github.com/daun/processwire-dashboard/releases/tag/v1.2.1 296 | [1.2.0]: https://github.com/daun/processwire-dashboard/releases/tag/v1.2.0 297 | [1.1.1]: https://github.com/daun/processwire-dashboard/releases/tag/v1.1.1 298 | [1.1.0]: https://github.com/daun/processwire-dashboard/releases/tag/v1.1.0 299 | [1.0.3]: https://github.com/daun/processwire-dashboard/releases/tag/v1.0.3 300 | [1.0.2]: https://github.com/daun/processwire-dashboard/releases/tag/v1.0.2 301 | [1.0.1]: https://github.com/daun/processwire-dashboard/releases/tag/v1.0.1 302 | [1.0.0]: https://github.com/daun/processwire-dashboard/releases/tag/v1.0.0 303 | [0.7.3]: https://github.com/daun/processwire-dashboard/releases/tag/v0.7.3 304 | [0.7.2]: https://github.com/daun/processwire-dashboard/releases/tag/v0.7.2 305 | [0.7.1]: https://github.com/daun/processwire-dashboard/releases/tag/v0.7.1 306 | [0.7.0]: https://github.com/daun/processwire-dashboard/releases/tag/v0.7.0 307 | [0.6.9]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.9 308 | [0.6.8]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.8 309 | [0.6.7]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.7 310 | [0.6.6]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.6 311 | [0.6.5]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.5 312 | [0.6.4]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.4 313 | [0.6.3]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.3 314 | [0.6.2]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.2 315 | [0.6.1]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.1 316 | [0.6.0]: https://github.com/daun/processwire-dashboard/releases/tag/v0.6.0 317 | [0.5.1]: https://github.com/daun/processwire-dashboard/releases/tag/v0.5.1 318 | [0.5.0]: https://github.com/daun/processwire-dashboard/releases/tag/v0.5.0 319 | [0.4.14]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.14 320 | [0.4.13]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.13 321 | [0.4.12]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.12 322 | [0.4.11]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.11 323 | [0.4.10]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.10 324 | [0.4.9]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.9 325 | [0.4.8]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.8 326 | [0.4.7]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.7 327 | [0.4.6]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.6 328 | [0.4.5]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.5 329 | [0.4.4]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.4 330 | [0.4.3]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.3 331 | [0.4.2]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.2 332 | [0.4.1]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.1 333 | [0.4.0]: https://github.com/daun/processwire-dashboard/releases/tag/v0.4.0 334 | [0.3.0]: https://github.com/daun/processwire-dashboard/releases/tag/v0.3.0 335 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to This Project 2 | 3 | ## Bug reports and feature submissions 4 | 5 | To submit an issue or request a feature, please do so on [Github](https://github.com/daun/processwire-dashboard/issues). 6 | 7 | If you file a bug report, your issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a bug report is to make it easy for yourself - and others - to replicate the bug and develop a fix. 8 | 9 | Remember, bug reports are created in the hope that others with the same problem will be able to collaborate with you on solving it. Do not expect that the bug report will automatically see any activity or that others will jump to fix it. Creating a bug report serves to help yourself and others start on the path of fixing the problem. 10 | 11 | ## Versioning scheme 12 | 13 | The project follows [semantic versioning](https://semver.org/). Minor and patch releases should never contain breaking changes. Make sure to check for upgrade instructions and test your code before upgrading major versions which **will** contain breaking changes. 14 | 15 | The `VERSION` file at the root of the project needs to be updated and a Git tag created to properly release a new version. 16 | 17 | ## Pull Requests 18 | 19 | All bug fixes and feature submissions should be sent to the `develop` branch. The `master` branch is for tagged releases only. 20 | 21 | Please send coherent history: make sure each individual commit in your pull request is meaningful. If you had to make a lot of intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 22 | 23 | ## Coding style 24 | 25 | - PHP: [PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md). 26 | - Javascript: [Standard](https://standardjs.com/), [Vue ESLint Essentials](https://github.com/vuejs/eslint-plugin-vue). 27 | -------------------------------------------------------------------------------- /Dashboard.css: -------------------------------------------------------------------------------- 1 | :root{--dashboard-grid-cols:12;--dashboard-grid-gap:25px;--dashboard-grid-gap-large:40px;--dashboard-grid-size-micro:2;--dashboard-grid-size-mini:3;--dashboard-grid-size-small:4;--dashboard-grid-size-normal:6;--dashboard-grid-size-large:8;--dashboard-color-body-bg:#f0f3f7;--dashboard-color-panel-bg:#fff;--dashboard-color-separator:#e5e5e5;--dashboard-color-text-light:#97aab4;--dashboard-color-icon:#97aab4;--dashboard-color-button-bg:#f0f3f7;--dashboard-color-button-border:#d7dadf;--dashboard-color-button-text:#354b60;--dashboard-color-button-hover-bg:#6c8dae;--dashboard-color-button-hover-text:#fff;--dashboard-color-th-bg:#fafbfc;--dashboard-color-list-hover-bg:#fafbfc;--dashboard-color-success:#42ae7a;--dashboard-color-success-bg:#edf8f2;--dashboard-color-warning:#eb7419;--dashboard-color-warning-bg:#fdeee3;--dashboard-color-error:#e24b40;--dashboard-color-error-bg:#fceeed;--dashboard-color-progress:#354b60;--dashboard-color-progress-bg:#c4ced4;--dashboard-fontsize-icon:1.25em;--dashboard-card-shadow:0 5px 15px rgba(0,0,0,.08);--dashboard-card-border-radius:4px;--dashboard-card-padding-content-x:20px;--dashboard-card-padding-content-y:20px;--dashboard-card-padding-header-x:20px;--dashboard-card-padding-header-y:13px;--dashboard-card-padding-list-y:10px}@media (min-width:768px){:root{--dashboard-grid-gap-large:75px}}body.Dashboard{background-color:var(--dashboard-color-body-bg);min-height:100vh}.AdminThemeUikit.Dashboard #pw-footer{margin-bottom:0}.AdminThemeReno.Dashboard #main,.AdminThemeReno.Dashboard #breadcrumbs,.AdminThemeReno.Dashboard #headline,.AdminThemeReno.Dashboard #content,.AdminThemeReno.Dashboard #footer,.AdminThemeDefault.Dashboard #main,.AdminThemeDefault.Dashboard #breadcrumbs,.AdminThemeDefault.Dashboard #headline,.AdminThemeDefault.Dashboard #content,.AdminThemeDefault.Dashboard #footer{background-color:var(--dashboard-color-body-bg)}.AdminThemeDefault.Dashboard #breadcrumbs{border-color:transparent}.Dashboard__panel p:first-child{margin-top:0}.Dashboard__panel p:last-child{margin-bottom:0}.uk-card{background:var(--dashboard-color-panel-bg);box-shadow:var(--dashboard-card-shadow);box-sizing:border-box;border-radius:var(--dashboard-card-border-radius);transition:box-shadow .1s ease-in-out;position:relative}.uk-card-header{border-bottom:1px solid var(--dashboard-color-separator)}.uk-card-title{line-height:1;margin:0!important}.uk-card-small .uk-card-body,.uk-card-small.uk-card-body{padding:var(--dashboard-card-padding-content-y)var(--dashboard-card-padding-content-x)}.uk-card-small .uk-card-header,.uk-card-small .uk-card-footer{padding:var(--dashboard-card-padding-header-y)var(--dashboard-card-padding-header-x)}.DashboardNoHeadline #pw-content-head h1{display:none}.DashboardNoHeadline #pw-content-body{padding-top:20px}.Dashboard__info{padding-top:3em}.Dashboard__info a{color:inherit}.Dashboard__getStarted{text-align:center;flex-direction:column;justify-content:center;align-items:center;min-height:80vh;display:flex}.Dashboard__getStarted p{margin-bottom:0}.Dashboard__getStarted p:nth-child(2){font-size:1.25em;font-weight:700}.Dashboard__getStarted p:nth-child(3) a{color:inherit;text-decoration:underline}.Dashboard__getStarted p:nth-child(4){margin-top:2em}.Dashboard__getStarted .fa{color:var(--dashboard-color-icon);font-size:4em}.Dashboard__grid{grid-template-columns:repeat(var(--dashboard-grid-cols),1fr);grid-gap:var(--dashboard-grid-gap);align-items:stretch;display:grid}.Dashboard__panel{grid-column:span var(--dashboard-grid-cols);flex-direction:column;display:flex}@media (min-width:768px){.Dashboard__panel{grid-column:span var(--dashboard-grid-size-normal)}.Dashboard__panel[data-size=micro]{grid-column:span var(--dashboard-grid-size-micro)}.Dashboard__panel[data-size=mini]{grid-column:span var(--dashboard-grid-size-mini)}.Dashboard__panel[data-size=small]{grid-column:span var(--dashboard-grid-size-small)}.Dashboard__panel[data-size=large]{grid-column:span var(--dashboard-grid-size-large)}.Dashboard__panel[data-size=full]{grid-column:span var(--dashboard-grid-cols)}}.Dashboard__panel[data-align=top]{align-self:start}.Dashboard__panel[data-align=bottom]{align-self:end}.Dashboard__panel[data-align=center]{align-self:center}.Dashboard__tabs+.Dashboard__grid{margin-top:var(--dashboard-grid-gap-large)}.Dashboard__tabs__list{margin:0 0 var(--dashboard-grid-gap);flex-wrap:wrap;padding:0;font-size:1.25rem;font-weight:700;list-style:none;display:flex}.Dashboard__tabs__list li:not(:first-child){margin-left:.5em}.Dashboard__tabs__list li:not(:last-child){margin-right:.5em}.Dashboard__tabs__list a{color:var(--dashboard-color-text-light);text-decoration:none;transition:color .2s ease-in-out}.Dashboard__tabs__list li a[aria-current=true],.Dashboard__tabs__list a:hover{color:inherit;text-decoration:none}.Dashboard__tabs__content[aria-hidden=true]{display:none}.Dashboard__tabs[data-cloak] .Dashboard__tabs__list li:first-child a{color:inherit}.Dashboard__tabs[data-cloak] .Dashboard__tabs__content+.Dashboard__tabs__content{display:none}.Dashboard__panels>.Dashboard__tabs{margin-top:var(--dashboard-grid-gap)}.Dashboard__group{flex-direction:column;align-items:stretch;display:flex}.Dashboard__group[data-margin=true]{margin-bottom:var(--dashboard-grid-gap-large)}.Dashboard__group__title{color:inherit;font-size:1.25rem;font-weight:700;margin:0 0 var(--dashboard-grid-gap)0!important}.Dashboard__group__title .fa{display:none}.Dashboard__group .Dashboard__grid{flex:1 0 auto;align-content:stretch}.Dashboard__group[data-align=top] .Dashboard__group .Dashboard__grid{align-content:start}.Dashboard__group[data-align=bottom] .Dashboard__group .Dashboard__grid{align-content:end}.Dashboard__group[data-align=center] .Dashboard__group .Dashboard__grid{align-content:center}.Dashboard__group[data-align=distribute] .Dashboard__group .Dashboard__grid{align-content:space-between}.Dashboard__group[data-align=fill] .Dashboard__group .Dashboard__grid{align-content:stretch}.Dashboard__panel{min-width:0}.Dashboard__panel:not(.Dashboard__group){overflow:hidden}.Dashboard__panel .uk-card-title{color:inherit;font-size:1rem;font-weight:700}.Dashboard__panel .uk-card-title .fa{opacity:.65;margin-right:.4em;font-size:1.15em;display:none;position:relative;top:.05em}.Dashboard[data-icons=true] .Dashboard__panel .uk-card-title .fa{display:inline-block}.Dashboard__panel .uk-card-body{flex-grow:1}.Dashboard__panel[data-style-center-title=true] .uk-card-header{text-align:center}.Dashboard__panel[data-style-borders=false] .uk-card-header,.Dashboard__panel[data-style-borders=false] .uk-card-footer{border:none}.Dashboard__panel[data-style-padding=false] .uk-card-body{padding:0}.Dashboard__panel[data-style-minimal=true]{box-shadow:none;background:0 0;border-radius:0}.Dashboard__panel[data-style-minimal=true] .uk-card-header,.Dashboard__panel[data-style-minimal=true] .uk-card-footer{border:none}.AdminThemeUikit .DashboardButton--light .ui-button{background:var(--dashboard-color-button-bg);color:var(--dashboard-color-button-text)}.AdminThemeUikit .DashboardButton--light .ui-button.ui-state-hover{background:var(--dashboard-color-button-hover-bg);color:var(--dashboard-color-button-hover-text)}.Dashboard__panel .uk-card-body>.pw-table-responsive{margin:-20px -20px -21px}.Dashboard__panel .uk-card-body>.AdminDataTable{margin-top:-10px;margin-bottom:0}.AdminThemeUikit .Dashboard__panel .AdminDataTable th{background-color:var(--dashboard-color-th-bg)}.AdminThemeUikit .Dashboard__panel .AdminDataTable th:first-child,.AdminThemeUikit .Dashboard__panel .AdminDataTable td:first-child{padding-left:20px}.AdminThemeUikit .Dashboard__panel .AdminDataTable th:last-child,.AdminThemeUikit .Dashboard__panel .AdminDataTable td:last-child{padding-right:20px}.DashboardPagination{margin:0}.DashboardPagination>*>*{background:var(--dashboard-color-button-bg);color:var(--dashboard-color-button-text);border-color:var(--dashboard-color-button-border);transition:background .2s,color .2s,border .2s}.DashboardPagination>*>:hover,.DashboardPagination>*>:focus,.DashboardPagination>.uk-active>*,.DashboardPagination>.uk-active>*>*{background:var(--dashboard-color-button-hover-bg);color:var(--dashboard-color-button-hover-text);border-color:var(--dashboard-color-button-hover-bg)}.DashboardPaginationSeparator{pointer-events:none}.DashboardFooterButtons{flex-wrap:wrap;margin:-.5em;display:flex}.DashboardFooterButtons>*{margin:.5em;display:inline-block}.DashboardFooterButtons .ui-button{margin-right:0}.DashboardFooterPagination{flex-wrap:wrap;justify-content:flex-end;align-items:baseline;margin-bottom:-.5em;margin-left:auto;margin-right:-1em;padding-left:1em;display:flex}.DashboardFooterPagination>*{margin-bottom:.5em;margin-right:1em} -------------------------------------------------------------------------------- /Dashboard.js: -------------------------------------------------------------------------------- 1 | !function(){var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},t={},n={},a=e.parcelRequire9ec1;null==a&&((a=function(e){if(e in t)return t[e].exports;if(e in n){var a=n[e];delete n[e];var r={id:e,exports:{}};return t[e]=r,a.call(r.exports,r,r.exports),r.exports}var o=Error("Cannot find module '"+e+"'");throw o.code="MODULE_NOT_FOUND",o}).register=function(e,t){n[e]=t},e.parcelRequire9ec1=a),(0,a.register)("lKEtu",function(e,t){function n(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}Object.defineProperty(e.exports,"_",{get:function(){return n},set:void 0,enumerable:!0,configurable:!0})});var r=a("lKEtu");function o(e){for(var t=1;t2&&void 0!==arguments[2]?arguments[2]:{},a=parseInt(e.data("key"),10),r=e.data("panel"),i=o({$element:e,key:a,panel:r},n),l=$.Event("dashboard:".concat(t)),s=$.Event("dashboard:".concat(t,"(").concat(r,")"));return $(document).trigger(l,[i]),$(document).trigger(s,[i]),!(l.isDefaultPrevented()||s.isDefaultPrevented())}},{key:"setupTabs",value:function(e){var t=e.find(this.selectors.tabLink),n=function(e,t){var n=$(e).attr("href");$(e).attr("aria-current",t?"true":"false"),$(n).attr("aria-hidden",t?"false":"true")},a=function(e){t.each(function(e,t){return n(t,!1)}),n(e,!0)};a(t.eq(0)),t.on("click",function(e){e.preventDefault(),a(e.target)}),e.attr("data-cloak",null)}},{key:"setupPanel",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];t&&function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:document;"undefined"!=typeof UIkit?function(e){$(".tooltip, .pw-tooltip",e).each(function(){$(this).removeClass("tooltip pw-tooltip"),UIkit.tooltip($(this))})}(e):function(e){$("a.tooltip, .pw-tooltip",e).tooltip({position:{my:"center bottom",at:"center top"}}).hover(function(){var e=$(this);e.is("a")?e.addClass("ui-state-hover"):(e.data("pw-tooltip-cursor",e.css("cursor")),e.css("cursor","pointer")),e.addClass("pw-tooltip-hover"),e.css("cursor","pointer")},function(){var e=$(this);e.removeClass("pw-tooltip-hover ui-state-hover"),e.is("a")||e.css("cursor",e.data("pw-tooltip-cursor"))})}(e)}(e),this.triggerPanelReadyEvent(e)}},{key:"setupReloadEvents",value:function(){var e=this;$(document).on("reload",this.selectors.panel,function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},a=n.animate,r=n.params;e.reloadPanel($(t.target),{animate:a,params:r})})}},{key:"setupAutoReload",value:function(){this.$panels.each(function(e,t){var n=$(t),a=parseInt(n.data("key"),10),r=parseInt(n.data("interval"),10);a>=0&&r>0&&setInterval(function(){n.trigger("reload")},r=Math.max(2e3,r))})}},{key:"reloadPanel",value:function(e){var t=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},a=n.animate,r=void 0!==a&&a,i=n.params;if(e.length){var l=o({dashboard:1,key:parseInt(e.data("key"),10),panel:e.data("panel")},(void 0===i?null:i)||{});$.post(this.url,l,null,"text").done(function(n){var a=$(n);if(t.triggerPanelEvent(e,"reload",{$new:a})){var o=function(){e.html(a.html()),e.prop("className",a.prop("className")),a.filter("script").each(function(e,t){$.globalEval(t.text||t.textContent||t.innerHTML||"")}),t.setupPanel(e,!0)};r?e.children().fadeOut(400,function(){o(),e.children().fadeIn(400)}):o()}}).fail(function(){console.error("Error fetching panel contents")})}}}],function(e,t){for(var n=0;n 17 | * @license GPL-3.0 18 | */ 19 | 20 | #[\AllowDynamicProperties] 21 | abstract class DashboardPanel extends Wire implements Module 22 | { 23 | /** 24 | * Instance of the main dashboard module. 25 | * 26 | * @var Dashboard 27 | */ 28 | private $dashboard = null; 29 | 30 | /** 31 | * Options passed to this instance. 32 | * 33 | * @var array 34 | */ 35 | protected $options = []; 36 | 37 | /** 38 | * Data passed to this instance. 39 | * 40 | * @var array 41 | */ 42 | protected $data = []; 43 | 44 | /** 45 | * Open links in the current tab. 46 | */ 47 | const windowModeNone = 'none'; // regular link 48 | /** 49 | * Open links in a modal popup. 50 | */ 51 | const windowModeModal = 'modal'; // opens modal 52 | /** 53 | * Open links in a new tab. 54 | */ 55 | const windowModeBlank = 'blank'; // opens target=_blank 56 | 57 | /** 58 | * Buttons displayed as floating modal buttons. 59 | */ 60 | const modalButtons = '#submit_publish, #submit_save, #submit_save_unpublished, #Inputfield_submit_save'; 61 | /** 62 | * Buttons that automatically close a modal after save (edit-page screen). 63 | */ 64 | const modalAutocloseEdit = '#submit_publish, #submit_save_unpublished, #submit_save'; 65 | /** 66 | * Buttons that automatically close a modal after save (add-page screen). 67 | */ 68 | const modalAutocloseAdd = '#submit_publish, #submit_save_unpublished'; 69 | 70 | /** 71 | * Module info stub to be extended by the implementing panel modules. 72 | */ 73 | public static function getModuleInfo() 74 | { 75 | return [ 76 | 'title' => __('Dashboard Panel: Base Class', __FILE__), 77 | 'requires' => 'Dashboard', 78 | 'installs' => 'Dashboard', 79 | 'icon' => 'compass', 80 | 'autoload' => false, 81 | 'singular' => false, 82 | ]; 83 | } 84 | 85 | /** 86 | * Constructor. 87 | */ 88 | public function __construct() 89 | { 90 | $this->dashboard = $this->modules->Dashboard; 91 | $this->viewFolder = $this->config->paths->{$this} . 'views'; 92 | } 93 | 94 | /** 95 | * Setup the panel: fetch data, do calculations, check for config errors, etc. 96 | * 97 | * @return bool 98 | */ 99 | public function setup() 100 | { 101 | return true; 102 | } 103 | 104 | /** 105 | * Get the panel's FontAwesome icon code (without the fa- prefix). 106 | * 107 | * @return string Icon code 108 | */ 109 | public function getIcon() 110 | { 111 | return ''; 112 | } 113 | 114 | /** 115 | * Get the panel's title. 116 | * 117 | * @return string Panel title 118 | */ 119 | public function getTitle() 120 | { 121 | return ''; 122 | } 123 | 124 | /** 125 | * Get the panel's main content. 126 | * 127 | * @return string Panel content 128 | */ 129 | abstract public function getContent(); 130 | 131 | /** 132 | * Get the panel's footer. 133 | * 134 | * @return string Panel footer 135 | */ 136 | public function getFooter() 137 | { 138 | return ''; 139 | } 140 | 141 | /** 142 | * Get a list of additional class names for the panel card. 143 | * 144 | * @return array Array of class names 145 | */ 146 | public function getClassNames() 147 | { 148 | return []; 149 | } 150 | 151 | /** 152 | * Get a list of additional HTML attributes for the panel card. 153 | * 154 | * @return array Array of attributes (['attr' => 'value']) 155 | */ 156 | public function getAttributes() 157 | { 158 | return []; 159 | } 160 | 161 | /** 162 | * Get a list of the panel's required stylesheets. 163 | * 164 | * @return array Array of file names or URLs 165 | */ 166 | public function getStylesheets() 167 | { 168 | return []; 169 | } 170 | 171 | /** 172 | * Get a list of the panel's required script files. 173 | * 174 | * @return array Array of file names or URLs 175 | */ 176 | public function getScripts() 177 | { 178 | return []; 179 | } 180 | 181 | /** 182 | * Get the default style options of this panel. 183 | * 184 | * @return array Array of style options (['attr' => 'value']) 185 | */ 186 | public function getStyleOptions() 187 | { 188 | return []; 189 | } 190 | 191 | /** 192 | * Get the interval at which this panel will auto-reload via AJAX. 193 | * 194 | * @return int Reload interval (milliseconds) 195 | */ 196 | public function getInterval() 197 | { 198 | return 0; 199 | } 200 | 201 | /** 202 | * Render the panel markup. 203 | * 204 | * @param array $options Options passed to this panel instance 205 | * @param int $key Key of this instance among all panels 206 | * 207 | * @return string 208 | */ 209 | final public function render($options, $key = -1) 210 | { 211 | // Create shortcut properties 212 | $this->options = $options; 213 | $this->name = $options['panel'] ?? ''; 214 | $this->class = "$this"; 215 | $this->data = $options['dataArray'] ?? []; 216 | $this->size = $this->dashboard->sanitizePanelSize($options['size'] ?? false); 217 | $this->style = $options['style'] ?? null; 218 | $this->align = $options['align'] ?? ''; 219 | 220 | // Setup panel, abort if negative return 221 | $status = $this->setup(); 222 | if ($status === false) { 223 | return ''; 224 | } 225 | 226 | // Update style with default options after setup 227 | $this->style ??= $this->getStyleOptions() ?? []; 228 | 229 | // Include scripts and stylesheets 230 | $this->includeFiles(); 231 | 232 | // Create output partials 233 | $icon = $this->renderIcon($options['icon'] ?? $this->getIcon()); 234 | $title = $options['title'] ?? $this->getTitle(); 235 | $content = $this->getContent(); 236 | $footer = $this->getFooter(); 237 | $interval = (int) ($options['interval'] ?? $this->getInterval()); 238 | $classNames = implode(' ', $this->getClassNames()); 239 | $attributes = $this->renderAttributes( 240 | array_merge( 241 | $this->generateStyleAttributes(), 242 | $this->getAttributes() 243 | ) 244 | ); 245 | 246 | // Render panel 247 | return $this->dashboard->view('panel', [ 248 | 'key' => $key, 249 | 'panel' => $this->name, 250 | 'module' => $this->class, 251 | 'options' => $this->options, 252 | 'size' => $this->size, 253 | 'data' => $this->data, 254 | 'style' => $this->style, 255 | 'align' => $this->align, 256 | 'interval' => $interval, 257 | 'classNames' => $classNames, 258 | 'attributes' => $attributes, 259 | 'icon' => $icon, 260 | 'title' => $title, 261 | 'content' => $content, 262 | 'footer' => $footer, 263 | ]); 264 | } 265 | 266 | /** 267 | * Render icon as markup. 268 | * 269 | * @param string $icon Icon code (without fa-) 270 | * 271 | * @return string 272 | */ 273 | final protected function renderIcon($icon) 274 | { 275 | return $this->dashboard->renderIcon($icon); 276 | } 277 | 278 | /** 279 | * Render data table. 280 | * 281 | * @param array $rows Table rows 282 | * @param array $options Array of options 283 | * 284 | * @return string 285 | */ 286 | protected function renderTable($rows, $options = []) 287 | { 288 | /** @var \ProcessWire\MarkupAdminDataTable $table */ 289 | $table = $this->modules->get('MarkupAdminDataTable'); 290 | 291 | $table->setSortable($options['sortable'] ?? false); 292 | $table->setEncodeEntities($options['entities'] ?? false); 293 | 294 | if ($options['header'] ?? false) { 295 | $header = array_shift($rows); 296 | if ($header) { 297 | $table->headerRow($header); 298 | } 299 | } 300 | if ($options['footer'] ?? false) { 301 | $footer = array_pop($rows); 302 | if ($footer) { 303 | $table->footerRow($footer); 304 | } 305 | } 306 | if ($options['class'] ?? false) { 307 | $table->addClass($options['class']); 308 | } 309 | 310 | foreach ($rows as $row) { 311 | $table->row($row); 312 | } 313 | 314 | return $table->render(); 315 | } 316 | 317 | /** 318 | * Render a button. 319 | * 320 | * @param string $href Link target 321 | * @param string $label Button label 322 | * @param array $options Array of options 323 | * 324 | * @return string 325 | */ 326 | protected function renderButton($href, $label, $options = []) 327 | { 328 | $icon = $options['icon'] ?? ''; 329 | $small = $options['small'] ?? null; 330 | $secondary = $options['secondary'] ?? null; 331 | $light = $options['light'] ?? null; 332 | $class = $options['class'] ?? ''; 333 | $modal = $options['modal'] ?? null; 334 | $blank = $options['blank'] ?? null; 335 | 336 | $button = $this->modules->get('InputfieldButton'); 337 | $button->attr('value', $label); 338 | $button->href = $href; 339 | $button->icon = $icon; 340 | $button->secondary = $secondary; 341 | $button->small = $small; 342 | $button->aclass = "DashboardButton {$class}"; 343 | // $button->class = ''; 344 | 345 | if ($light) { 346 | $button->aclass .= ' DashboardButton--light'; 347 | } 348 | if ($modal) { 349 | $this->includeModalScripts(); 350 | $button->class .= ' pw-modal pw-modal-large'; 351 | $buttons = $options['modalButtons'] ?? null; 352 | $autoclose = $options['modalAutoclose'] ?? null; 353 | $close = $options['modalClose'] ?? null; 354 | $reload = $options['reloadOnModalClose'] ?? false; 355 | if ($buttons) { 356 | $button->attr('data-buttons', $buttons); 357 | if ($autoclose) { 358 | $button->attr('data-autoclose', $autoclose); 359 | } 360 | if ($close) { 361 | $button->attr('data-close', $close); 362 | } 363 | if ($reload) { 364 | $button->attr('data-reload-on-close', true); 365 | } 366 | } 367 | } elseif ($blank) { 368 | $button->attr('target', '_blank'); 369 | } 370 | 371 | return $button->render(); 372 | } 373 | 374 | /** 375 | * Render a footer button. Identical to renderButton(), but with smaller & lighter buttons by default. 376 | * 377 | * @param string $href Link target 378 | * @param string $label Button label 379 | * @param array $options Array of options 380 | * 381 | * @return string 382 | */ 383 | protected function renderFooterButton($href, $label, $options = []) 384 | { 385 | $options['light'] = $options['secondary'] ?? false; 386 | $options['small'] = true; 387 | $options['secondary'] = true; 388 | 389 | return $this->renderButton($href, $label, $options); 390 | } 391 | 392 | /** 393 | * Render attribute array as HTML attribute string. 394 | * 395 | * @param array $attributes Attributes (['attr' => 'value']) 396 | * 397 | * @return string 398 | */ 399 | protected function renderAttributes($attributes = []) 400 | { 401 | if (!is_array($attributes)) { 402 | return $attributes; 403 | } 404 | 405 | if (empty($attributes)) { 406 | return ''; 407 | } 408 | 409 | $attributePairs = []; 410 | foreach ($attributes as $key => $val) { 411 | if ($val === false || $val === null) { 412 | continue; 413 | } 414 | if (is_int($key)) { 415 | $attributePairs[] = $val; 416 | continue; 417 | } 418 | $val = htmlspecialchars((string) $val, ENT_QUOTES); 419 | $attributePairs[] = "{$key}=\"{$val}\""; 420 | } 421 | 422 | return implode(' ', $attributePairs); 423 | } 424 | 425 | /** 426 | * Add/update query parameters on a url. 427 | */ 428 | 429 | /** 430 | * Add/update query parameters on a url (either ? or &). 431 | * 432 | * @param string $url URL to update 433 | * @param string $key Query parameter to add/change 434 | * @param string $value Value to set parameter to 435 | * 436 | * @return string Updated URL 437 | */ 438 | protected function setQueryParameter($url, $key, $value = null) 439 | { 440 | $info = parse_url($url); 441 | $query = $info['query'] ?? ''; 442 | parse_str($query, $params); 443 | 444 | if (is_array($key)) { 445 | foreach ($key as $k => $v) { 446 | $params[$k] = $v; 447 | } 448 | } else { 449 | $params[$key] = $value; 450 | } 451 | 452 | $query = http_build_query($params); 453 | 454 | $result = $info['path'] ?? ''; 455 | if ($info['host'] ?? false) { 456 | $origin = $info['scheme'].'://'.$info['host']; 457 | $result = $origin.$result; 458 | } 459 | if ($query) { 460 | $result .= '?'.$query; 461 | } 462 | 463 | return $result; 464 | } 465 | 466 | /** 467 | * Generate HTML attribute array from style options. 468 | */ 469 | protected function generateStyleAttributes() 470 | { 471 | // Map styles array, and also transform the keys 472 | return array_column( 473 | array_map(function ($option, $value) { 474 | $option = $this->sanitizer->kebabCase($option); 475 | $key = "data-style-{$option}"; 476 | $value = $value ? 'true' : 'false'; 477 | 478 | return [$key, $value]; 479 | }, array_keys($this->style), $this->style), 480 | 1, 481 | 0 482 | ); 483 | } 484 | 485 | /** 486 | * Include required JS files for modal popups. 487 | * 488 | * @return void 489 | */ 490 | protected function includeModalScripts() 491 | { 492 | $this->modules->get('JqueryUI')->use('modal'); 493 | } 494 | 495 | /** 496 | * Include module scripts and stylesheets. 497 | * 498 | * @return void 499 | */ 500 | final protected function includeFiles() 501 | { 502 | $modulePath = $this->config->paths->$this; 503 | $moduleUrl = $this->config->urls->$this; 504 | $version = $this->modules->getModuleInfoProperty($this, 'version'); 505 | 506 | $templatePath = $this->config->paths->templates; 507 | $templateUrl = $this->config->urls->templates; 508 | 509 | // Stylesheets 510 | $styles = (array) $this->getStylesheets(); 511 | $styles[] = "{$this}.css"; 512 | foreach ($styles as $file) { 513 | if (stripos($file, '://') !== false) { 514 | $this->config->styles->add($file); 515 | } elseif (file_exists($modulePath.$file)) { 516 | $this->config->styles->add("{$moduleUrl}{$file}?v={$version}"); 517 | } elseif (file_exists($templatePath.$file)) { 518 | $this->config->styles->add("{$templateUrl}{$file}?v={$version}"); 519 | } 520 | } 521 | 522 | // Scripts 523 | $scripts = (array) $this->getScripts(); 524 | $scripts[] = "{$this}.js"; 525 | foreach ($scripts as $file) { 526 | if (stripos($file, '://') !== false) { 527 | $this->config->scripts->add($file); 528 | } elseif (file_exists($modulePath.$file)) { 529 | $this->config->scripts->add("{$moduleUrl}{$file}?v={$version}"); 530 | } elseif (file_exists($templatePath.$file)) { 531 | $this->config->scripts->add("{$templateUrl}{$file}?v={$version}"); 532 | } 533 | } 534 | } 535 | 536 | /** 537 | * Render local view with supplied variables. 538 | * 539 | * @param string $view View name (filename relative to /views/ folder) 540 | * @param array $variables Array of variables (['var' => 'value']) 541 | * 542 | * @return string Rendered view 543 | */ 544 | final protected function view($view, $variables) 545 | { 546 | $filename = $this->viewFolder. DIRECTORY_SEPARATOR . $view; 547 | return $this->files->render($filename, $variables); 548 | } 549 | 550 | /** 551 | * Load a page from either an ID, a selector or return the page itself. 552 | */ 553 | 554 | /** 555 | * Load a page from either an ID, a selector or return the page itself. 556 | * 557 | * @param Page|int|string|null $input 558 | * 559 | * @return Page|NullPage|null 560 | */ 561 | protected function getPageFromObjectOrSelectorOrID($input) 562 | { 563 | if (!$input) { 564 | return null; 565 | } 566 | 567 | if (is_object($input) && $input instanceof Page) { 568 | $page = $input; 569 | } elseif (is_int($input)) { 570 | $page = $this->pages->get($input); 571 | } elseif (is_string($input) && Selectors::stringHasSelector($input)) { 572 | $page = $this->pages->get($input); 573 | } 574 | 575 | return $page ?? null; 576 | } 577 | } 578 | -------------------------------------------------------------------------------- /DashboardPanelAddNew.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelAddNew--dropdown .DashboardPanelAddNewDropdown{display:flex}.DashboardPanelAddNew--dropdown .DashboardPanelAddNewDropdown button:first-of-type{flex-grow:1}.DashboardPanelAddNew--dropdown .DashboardPanelAddNewDropdown button:last-of-type{margin-right:0!important}.DashboardPanelAddNew--list .uk-card-body{padding:0}.DashboardPanelAddNew--list ul{margin:0;padding:0;list-style:none}.DashboardPanelAddNew--list li{margin:0}.DashboardPanelAddNew--list a{color:inherit;padding:var(--dashboard-card-padding-list-y)var(--dashboard-card-padding-content-x);border-bottom:1px solid var(--dashboard-color-separator);white-space:nowrap;align-items:center;text-decoration:none;display:flex}.DashboardPanelAddNew--list a:hover{background:var(--dashboard-color-list-hover-bg)}.DashboardPanelAddNew--list a>span{align-items:center;display:flex}.DashboardPanelAddNew--list span+span{min-width:0}.DashboardPanelAddNew--list .fa{color:var(--dashboard-color-icon);margin-right:.5em;font-size:var(--dashboard-fontsize-icon)!important}.DashboardPanelAddNew--list a:hover .fa{color:inherit} -------------------------------------------------------------------------------- /DashboardPanelAddNew.module: -------------------------------------------------------------------------------- 1 | __('Dashboard Panel: Add New Page', __FILE__), 20 | 'summary' => __('Allow adding new pages from the dashboard', __FILE__), 21 | 'author' => 'Philipp Daun', 22 | 'version' => '1.5.9', 23 | ] 24 | ); 25 | } 26 | 27 | const displayOptions = [ 28 | 'list', 29 | 'dropdown', 30 | ]; 31 | 32 | const defaultDisplayOption = 'list'; 33 | 34 | public function getIcon() 35 | { 36 | return 'plus'; 37 | } 38 | 39 | public function getTitle() 40 | { 41 | if ($this->doesRenderTitle()) { 42 | return $this->getTitleString(); 43 | } else { 44 | return ''; 45 | } 46 | } 47 | 48 | protected function doesRenderTitle() 49 | { 50 | if ($this->display === 'dropdown') { 51 | return false; 52 | } else { 53 | return true; 54 | } 55 | } 56 | 57 | protected function getTitleString() 58 | { 59 | /** @var AdminThemeFramework $theme */ 60 | $theme = $this->wire('adminTheme'); 61 | $label = $theme->getAddNewLabel(); 62 | 63 | return $label ?? $this->_('Add New'); 64 | } 65 | 66 | public function getClassNames() 67 | { 68 | return ["{$this}--{$this->display}"]; 69 | } 70 | 71 | public function getStyleOptions() 72 | { 73 | if (!$this->doesRenderTitle()) { 74 | return [ 75 | 'minimal' => true, 76 | 'padding' => false, 77 | ]; 78 | } else { 79 | return []; 80 | } 81 | } 82 | 83 | public function getContent() 84 | { 85 | $actions = $this->getAvailablePageActions(); 86 | 87 | if (!count($actions)) { 88 | return ''; 89 | } 90 | 91 | switch ($this->display) { 92 | case 'dropdown': 93 | return $this->renderActionDropdown($actions); 94 | break; 95 | case 'list': 96 | default: 97 | return $this->renderActionList($actions); 98 | break; 99 | } 100 | } 101 | 102 | protected function renderActionList($actions) 103 | { 104 | $links = array_map(function ($item) { 105 | $icon = wireIconMarkup($item['icon'], 'fw'); 106 | $url = $item['url']; 107 | $label = $item['label']; 108 | return 109 | "
  • 110 | 111 | {$icon} 112 | {$label} 113 | 114 |
  • " 115 | ; 116 | }, $actions); 117 | 118 | $output = implode('', $links); 119 | $output = ""; 120 | 121 | return $output; 122 | } 123 | 124 | protected function renderActionDropdown($actions) 125 | { 126 | /** @var InputfieldSubmit $button */ 127 | $button = $this->modules->get('InputfieldSubmit'); 128 | $button->html = $this->getTitleString(); 129 | $button->setSecondary(); 130 | 131 | foreach ($actions as $item) { 132 | $button->addActionLink($item['url'], $item['label'], $item['icon']); 133 | } 134 | 135 | return "
    {$button->render()}
    "; 136 | } 137 | 138 | protected function getAvailablePageActions() 139 | { 140 | try { 141 | /** @var ProcessPageAdd $module */ 142 | $module = $this->modules->getModule('ProcessPageAdd', ['noInit' => true]); 143 | $data = $module->executeNavJSON(['getArray' => true]); 144 | $actions = []; 145 | 146 | foreach($data['list'] as $item) { 147 | if (strpos($item['url'], 'bookmarks/') === 0) continue; 148 | $item['url'] = $data['url'] . $item['url']; 149 | $item['icon'] = $item['icon'] ?? $data['icon']; 150 | $actions[] = $item; 151 | } 152 | } catch (\Throwable $th) { 153 | $actions = []; 154 | } 155 | 156 | return $actions; 157 | } 158 | 159 | public function setup() 160 | { 161 | parent::setup(); 162 | $this->display = $this->sanitizer->option($this->data['display'] ?? '', self::displayOptions) ?: self::defaultDisplayOption; 163 | $this->fallbackIcon = $this->data['fallbackIcon'] ?? 'bookmark-o'; 164 | $this->icon = $this->data['icon'] ?? null; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /DashboardPanelChart.css: -------------------------------------------------------------------------------- 1 | :root{--dashboard-panel-chart-ratio:2.5}.DashboardPanelChart__canvas{display:none}.DashboardPanelChart__placeholder{padding-bottom:calc(100%/var(--dashboard-panel-chart-ratio));height:0;position:relative;overflow:hidden}.DashboardPanelChart__canvas[style]+.DashboardPanelChart__placeholder{display:none} -------------------------------------------------------------------------------- /DashboardPanelChart.js: -------------------------------------------------------------------------------- 1 | !function(){function t(t){return t&&t.__esModule?t.default:t}var r,e,o,a="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},n={},l={},i={},u={};u=Array.isArray;var s={};function c(t){return t&&"undefined"!=typeof Symbol&&t.constructor===Symbol?"symbol":typeof t}var d={},f={},h={},b={},p={};p="object"==typeof a&&a&&a.Object===Object&&a;var g="object"==typeof self&&self&&self.Object===Object&&self;h=(b=p||g||Function("return this")()).Symbol;var v={},y=Object.prototype,_=y.hasOwnProperty,C=y.toString,m=h?h.toStringTag:void 0;v=function(t){var r=_.call(t,m),e=t[m];try{t[m]=void 0;var o=!0}catch(t){}var a=C.call(t);return o&&(r?t[m]=e:delete t[m]),a};var w={},j=Object.prototype.toString;w=function(t){return j.call(t)};var O=h?h.toStringTag:void 0;f=function(t){return null==t?void 0===t?"[object Undefined]":"[object Null]":O&&O in Object(t)?v(t):w(t)};var k={};k=function(t){return null!=t&&"object"==typeof t},d=function(t){return(void 0===t?"undefined":c(t))=="symbol"||k(t)&&"[object Symbol]"==f(t)};var S=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,z=/^\w*$/;s=function(t,r){if(u(t))return!1;var e=void 0===t?"undefined":c(t);return!!("number"==e||"symbol"==e||"boolean"==e||null==t||d(t))||z.test(t)||!S.test(t)||null!=r&&t in Object(r)};var P={},x={},F={},W={},B={},T={},A={},E={},R={},H={},L={};L=function(t){var r=void 0===t?"undefined":c(t);return null!=t&&("object"==r||"function"==r)},H=function(t){if(!L(t))return!1;var r=f(t);return"[object Function]"==r||"[object GeneratorFunction]"==r||"[object AsyncFunction]"==r||"[object Proxy]"==r};var M={},U={};U=b["__core-js_shared__"];var q=(o=/[^.]+$/.exec(U&&U.keys&&U.keys.IE_PROTO||""))?"Symbol(src)_1."+o:"";M=function(t){return!!q&&q in t};var D={},G=Function.prototype.toString;D=function(t){if(null!=t){try{return G.call(t)}catch(t){}try{return t+""}catch(t){}}return""};var I=/^\[object .+?Constructor\]$/,N=Object.prototype,Z=Function.prototype.toString,J=N.hasOwnProperty,K=RegExp("^"+Z.call(J).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");R=function(t){return!(!L(t)||M(t))&&(H(t)?K:I).test(D(t))};var Q={};Q=function(t,r){return null==t?void 0:t[r]},A=(E=function(t,r){var e=Q(t,r);return R(e)?e:void 0})(Object,"create"),T=function(){this.__data__=A?A(null):{},this.size=0};var V={};V=function(t){var r=this.has(t)&&delete this.__data__[t];return this.size-=r?1:0,r};var X={},Y=Object.prototype.hasOwnProperty;X=function(t){var r=this.__data__;if(A){var e=r[t];return"__lodash_hash_undefined__"===e?void 0:e}return Y.call(r,t)?r[t]:void 0};var tt={},tr=Object.prototype.hasOwnProperty;tt=function(t){var r=this.__data__;return A?void 0!==r[t]:tr.call(r,t)};var te={};function to(t){var r=-1,e=null==t?0:t.length;for(this.clear();++r-1};var tf={};function th(t){var r=-1,e=null==t?0:t.length;for(this.clear();++r-1&&t%1==0&&t __('Dashboard Panel: Chart', __FILE__), 16 | 'summary' => __('Display a customizable chart from any data source', __FILE__), 17 | 'author' => 'Philipp Daun', 18 | 'version' => '1.5.9', 19 | ] 20 | ); 21 | } 22 | 23 | public function getIcon() 24 | { 25 | return 'pie-chart'; 26 | } 27 | 28 | public function getContent() 29 | { 30 | return $this->view('panels/chart', [ 31 | 'theme' => $this->theme, 32 | 'default' => $this->defaultTheme, 33 | 'chart' => $this->chart, 34 | 'padding' => $this->padding, 35 | ]); 36 | } 37 | 38 | public function setup() 39 | { 40 | parent::setup(); 41 | $this->theme = $this->data['theme'] ?? false; 42 | $this->defaultTheme = $this->getDefaultTheme(); 43 | $this->chart = $this->parseChartData($this->data['chart'] ?? []); 44 | $this->aspectRatio = $this->chart['options']['aspectRatio'] ?? 0; 45 | $this->padding = $this->aspectRatioPadding($this->aspectRatio); 46 | } 47 | 48 | public function getScripts() 49 | { 50 | return ['https://cdn.jsdelivr.net/npm/chart.js@2']; 51 | } 52 | 53 | /** 54 | * Manipulate the chart data if necessary. 55 | */ 56 | public function ___parseChartData($data) 57 | { 58 | return $data; 59 | } 60 | 61 | /** 62 | * Get the name of the default color theme. 63 | */ 64 | public function ___getDefaultTheme() 65 | { 66 | return 'processwire'; 67 | } 68 | 69 | /** 70 | * Calculate bottom padding for aspect ratio placeholder. 71 | */ 72 | protected function aspectRatioPadding($ratio) 73 | { 74 | if ($ratio <= 0) { 75 | return ''; 76 | } 77 | $padding = 100 / $ratio; 78 | $padding = number_format($padding, 2, '.', ''); 79 | 80 | return "padding-bottom: {$padding}%;"; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /DashboardPanelCollection.css: -------------------------------------------------------------------------------- 1 | :root{--dashboard-panel-collection-img-radius:4px;--dashboard-panel-collection-img-height:30px;--dashboard-panel-collection-color-radius:50%}.DashboardPanelCollection .uk-card-footer{color:var(--dashboard-color-text-light);font-size:.85em}.DashboardPanelCollection .uk-card-footer>div{justify-content:space-between;align-items:baseline;display:flex}.DashboardPanelCollection td:first-child,.DashboardPanelCollection .DashboardTableColumn__title{font-weight:700}.DashboardTableColumn__actions__{white-space:nowrap;text-align:right!important}.DashboardTableColumn__actions__ .tablesorter-header-inner{display:none}.DashboardTableColumn__actions__ a,.DashboardTableColumn__actions__ .fa{display:inline-block}.DashboardTableColumn__actions__>:not(:first-child){margin-left:.25em}.DashboardTableColumn__actions__ .fa{cursor:not-allowed;color:var(--dashboard-color-icon);opacity:.4}.DashboardTableColumn__actions__ a .fa{opacity:1;cursor:inherit}.DashboardTableColumn__page_icon{width:40px}.AdminThemeUikit .DashboardPanelCollection td.is-image-column{vertical-align:middle;padding-top:7px;padding-bottom:7px}.DashboardPanelCollection td.is-image-column img{border-radius:var(--dashboard-panel-collection-img-radius);max-height:var(--dashboard-panel-collection-img-height);width:auto}.DashboardPanelCollection td.is-color-column{vertical-align:middle}.DashboardPanelCollection td.is-color-column span{border-radius:var(--dashboard-panel-collection-color-radius);width:1em;height:1em;display:inline-block}.uk-switch{--switch-width:1.9em;--switch-height:1.1em;--switch-pointer-offset:2px;--switch-pointer-size:calc(var(--switch-height) - 2*var(--switch-pointer-offset));--switch-active-color:var(--dashboard-color-button-hover-bg,currentColor);width:var(--switch-width);height:var(--switch-height);display:inline-block;position:relative;-ms-transform:translateY(.2em);transform:translateY(.2em)}.uk-switch input{display:none}.uk-switch-slider{cursor:pointer;background-color:rgba(0,0,0,.22);border-radius:999vw;transition-property:background-color;transition-duration:.2s;position:absolute;top:0;bottom:0;left:0;right:0}.uk-switch-slider:before{content:"";width:var(--switch-pointer-size);height:var(--switch-pointer-size);left:var(--switch-pointer-offset);bottom:var(--switch-pointer-offset);background-color:#fff;border-radius:50%;transition-property:-ms-transform,-ms-transform,transform,box-shadow;transition-duration:.2s;position:absolute}input:checked+.uk-switch-slider{background-color:var(--switch-active-color)!important}input:checked+.uk-switch-slider:before{-ms-transform:translateX(-100%)translateX(calc(var(--switch-width) - var(--switch-pointer-offset)*2));-ms-transform:translateX(-100%)translateX(calc(var(--switch-width) - var(--switch-pointer-offset)*2));transform:translateX(-100%)translateX(calc(var(--switch-width) - var(--switch-pointer-offset)*2))} -------------------------------------------------------------------------------- /DashboardPanelCollection.js: -------------------------------------------------------------------------------- 1 | var e,n,t,a,r;n={},t={},null==(a=(e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{}).parcelRequire9ec1)&&((a=function(e){if(e in n)return n[e].exports;if(e in t){var a=t[e];delete t[e];var r={id:e,exports:{}};return n[e]=r,a.call(r.exports,r,r.exports),r.exports}var o=Error("Cannot find module '"+e+"'");throw o.code="MODULE_NOT_FOUND",o}).register=function(e,n){t[e]=n},e.parcelRequire9ec1=a),(0,a.register)("lKEtu",function(e,n){function t(e,n,t){return n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t,e}Object.defineProperty(e.exports,"_",{get:function(){return t},set:void 0,enumerable:!0,configurable:!0})}),r=a("lKEtu"),$(document).on("dashboard:panel(collection)",function(e,n){!function(e){var n=function(n){e.trigger("reload",{animate:!1,params:n})},t=e.find(".pw-modal[data-reload-on-close]");if(t.length){var a={page:e.data("page")||1};t.on("pw-modal-closed",function(){e.trigger("reload",{animate:!1,params:a})})}var o=e.find("a[data-pagination]");o.length&&o.on("click",function(t){t.preventDefault();var a=(t.currentTarget.href.match(/\d+$/)||[])[0]||1;e.data("page",a),n({page:a})});var i=e.find("a[data-action]");i.length&&i.on("click",function(e){e.preventDefault();var t=e.currentTarget.dataset.action,a=e.currentTarget.dataset.actionConfirm,o=e.currentTarget.dataset.actionValue,i=(0,r._)({},"actions".concat(t),o);console.log(a),a?ProcessWire.confirm(a,function(){return n(i)}):n(i)});var c=e.find('input[name^="actions["]');c.length&&c.on("change",function(e){e.preventDefault();var t=e.target.name,a=e.target.checked;n((0,r._)({},t,a?1:""))})}(n.$element)}); -------------------------------------------------------------------------------- /DashboardPanelHelloWorld.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelHelloWorld .uk-card-body{font-style:italic} -------------------------------------------------------------------------------- /DashboardPanelHelloWorld.js: -------------------------------------------------------------------------------- 1 | $(document).on("dashboard:panel(hello-world)",function(o,l){var n;n=l.$element.find("p").length,console.log("Hello world! I found ".concat(n," paragraph(s)"))}); -------------------------------------------------------------------------------- /DashboardPanelHelloWorld.module: -------------------------------------------------------------------------------- 1 | __('Dashboard Panel: Hello World', __FILE__), 16 | 'summary' => __('An example dashboard panel', __FILE__), 17 | 'author' => 'Philipp Daun', 18 | 'version' => '0.0.1', 19 | ] 20 | ); 21 | } 22 | 23 | /** 24 | * Setup the panel: fetch data, do calculations, check for config errors, etc. 25 | */ 26 | public function setup() 27 | { 28 | $this->text = $this->data['text'] ?? 'Nothing to see here'; 29 | } 30 | 31 | /** 32 | * Get the panel's FontAwesome icon code (without the fa- prefix). 33 | */ 34 | public function getIcon() 35 | { 36 | return 'globe'; 37 | } 38 | 39 | /** 40 | * Get the panel's title. 41 | */ 42 | public function getTitle() 43 | { 44 | return $this->_('Hello World'); 45 | } 46 | 47 | /** 48 | * Get the panel's main content. 49 | */ 50 | public function getContent() 51 | { 52 | return "

    {$this->text}

    "; 53 | } 54 | 55 | /** 56 | * Get the panel's footer. 57 | */ 58 | public function getFooter() 59 | { 60 | return $this->_('Goodbye World'); 61 | } 62 | 63 | /** 64 | * Get a list of additional class names for the panel card. 65 | */ 66 | public function getClassNames() 67 | { 68 | return ['unique-classname-for-styling']; 69 | } 70 | 71 | /** 72 | * Get a list of the panel's stylesheets. 73 | */ 74 | public function getStylesheets() 75 | { 76 | return ['https://cdnjs.cloudflare.com/something.min.css']; 77 | } 78 | 79 | /** 80 | * Get a list of the panel's script files. 81 | */ 82 | public function getScripts() 83 | { 84 | return ['assets/something.min.js']; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /DashboardPanelInstance.class.php: -------------------------------------------------------------------------------- 1 | $unused) { 10 | return $key; 11 | } 12 | 13 | return null; 14 | } 15 | } 16 | 17 | /** 18 | * Dashboard panel instance. 19 | * 20 | * Wire-derived object that holds 21 | * the configuration of a single panel instance 22 | */ 23 | class DashboardPanelInstance extends WireData 24 | { 25 | } 26 | 27 | /** 28 | * Dashboard panel group. 29 | * 30 | * Wire-derived object that holds 31 | * the configuration of a group of panel instances 32 | */ 33 | class DashboardPanelGroup extends DashboardPanelInstance 34 | { 35 | public function add($item) 36 | { 37 | return $this->panels->add($item); 38 | } 39 | 40 | public function import($items) 41 | { 42 | return $this->panels->import($items); 43 | } 44 | 45 | public function insertBefore($item, $existingItem) 46 | { 47 | return $this->panels->insertBefore($item, $existingItem); 48 | } 49 | 50 | public function insertAfter($item, $existingItem) 51 | { 52 | return $this->panels->insertAfter($item, $existingItem); 53 | } 54 | 55 | public function remove($item) 56 | { 57 | return $this->panels->remove($item); 58 | } 59 | 60 | public function flatten() 61 | { 62 | return $this->panels->flatten(); 63 | } 64 | } 65 | 66 | /** 67 | * Dashboard panel tab. 68 | * 69 | * Wire-derived object that holds 70 | * the configuration of a tab of panel instances 71 | */ 72 | class DashboardPanelTab extends DashboardPanelGroup 73 | { 74 | } 75 | 76 | /** 77 | * Dashboard panel array class. 78 | * 79 | * WireArray that holds a collection of panel instances 80 | */ 81 | class DashboardPanelArray extends WireArray 82 | { 83 | protected $duplicateChecking = false; 84 | protected $frozen = false; 85 | 86 | public function freeze() 87 | { 88 | $this->frozen = true; 89 | } 90 | 91 | public function frozen() 92 | { 93 | return $this->frozen; 94 | } 95 | 96 | public function makeBlankItem() 97 | { 98 | return null; 99 | } 100 | 101 | public function isValidItem($item) 102 | { 103 | return $item instanceof DashboardPanelInstance; 104 | } 105 | 106 | /** 107 | * Flatten the panels in this array to include panels in any 108 | * panel groups. 109 | * 110 | * @return DashboardPanelArray 111 | */ 112 | public function flatten() 113 | { 114 | $flattened = new self(); 115 | 116 | /* Recursively flatten nested groups */ 117 | foreach ($this->getAll() as $item) { 118 | if ($item instanceof DashboardPanelGroup) { 119 | $flattened->import($item->flatten()); 120 | } elseif ($item instanceof DashboardPanelInstance) { 121 | $flattened->add($item); 122 | } 123 | } 124 | 125 | return $flattened; 126 | } 127 | 128 | public function add($config) 129 | { 130 | // Account for array of configs: all panel configs have associative 131 | // keys, so numerical keys indicate an array of configs 132 | if (is_array($config) && is_int(array_key_first($config))) { 133 | return $this->import($config); 134 | } else { 135 | return parent::add($this->createInstance($config)); 136 | } 137 | } 138 | 139 | public function set($key, $value) 140 | { 141 | return parent::set($key, $this->createInstance($value)); 142 | } 143 | 144 | public function prepend($item) 145 | { 146 | return parent::prepend($this->createInstance($item)); 147 | } 148 | 149 | public function unshift($item) 150 | { 151 | return parent::unshift($this->createInstance($item)); 152 | } 153 | 154 | public function insertBefore($item, $existingItem) 155 | { 156 | return parent::insertBefore($this->createInstance($item), $existingItem); 157 | } 158 | 159 | public function insertAfter($item, $existingItem) 160 | { 161 | return parent::insertAfter($this->createInstance($item), $existingItem); 162 | } 163 | 164 | public function import($items) 165 | { 166 | $instances = []; 167 | foreach ($items as $key => $item) { 168 | $instances[$key] = $this->createInstance($item); 169 | } 170 | 171 | return parent::import($instances); 172 | } 173 | 174 | /** 175 | * Create a panel and return it. 176 | * 177 | * @param array $config 178 | * 179 | * @return DashboardPanelGroup 180 | */ 181 | public function createPanel(array $config) 182 | { 183 | if (!is_array($config)) { 184 | return; 185 | } 186 | if (!($config['panel'] ?? false)) { 187 | throw new \Exception('Missing required `panel` parameter'); 188 | } 189 | 190 | $config['type'] = 'panel'; 191 | if ($config['data'] ?? false) { 192 | $config['dataArray'] = $config['data']; 193 | unset($config['data']); 194 | } 195 | $instance = new DashboardPanelInstance(); 196 | $instance->setArray($config); 197 | 198 | return $instance; 199 | } 200 | 201 | /** 202 | * Create a group of panels and return it. 203 | * 204 | * @param array $config 205 | * 206 | * @return DashboardPanelGroup 207 | */ 208 | public function createGroup(array $config) 209 | { 210 | if (!is_array($config)) { 211 | return; 212 | } 213 | 214 | $config['type'] = 'group'; 215 | $config['panels'] = new self(); 216 | $group = new DashboardPanelGroup(); 217 | $group->setArray($config); 218 | 219 | return $group; 220 | } 221 | 222 | /** 223 | * Create a tab and return it. 224 | * 225 | * @param array $config 226 | * 227 | * @return DashboardPanelTab 228 | */ 229 | public function createTab(array $config) 230 | { 231 | if (!is_array($config)) { 232 | return; 233 | } 234 | 235 | $config['type'] = 'tab'; 236 | $config['panels'] = new self(); 237 | $tab = new DashboardPanelTab(); 238 | $tab->setArray($config); 239 | 240 | return $tab; 241 | } 242 | 243 | /** 244 | * Create an instance of the DashboardPanelInstance class. 245 | * 246 | * @param array $config 247 | * 248 | * @return DashboardPanelInstance 249 | */ 250 | public function createInstance($config) 251 | { 252 | if (!is_array($config)) { 253 | return $config; 254 | } 255 | 256 | switch ($config['type'] ?? null) { 257 | case 'tab': 258 | return $this->createTab($config); 259 | case 'group': 260 | return $this->createGroup($config); 261 | case 'panel': 262 | default: 263 | return $this->createPanel($config); 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /DashboardPanelNotice.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelNotice .uk-card-header{display:none}.DashboardPanelNotice .uk-card-body{padding-top:15px;padding-bottom:15px;display:flex}.DashboardPanelNotice .uk-card-body div:last-child{margin-left:auto;padding-left:2em}.DashboardPanelNotice .uk-card-body div:last-child a{color:inherit;white-space:nowrap;padding-right:.5em;text-decoration:underline}.DashboardPanelNotice .uk-card-body div:last-child a:last-child{padding-right:0}.DashboardPanelNotice.notice-status--success{color:#42ae7a;background-color:#edf8f2}.DashboardPanelNotice.notice-status--warning{color:#eb7419;background-color:#fdeee3}.DashboardPanelNotice.notice-status--error{color:#e24b40;background-color:#fceeed} -------------------------------------------------------------------------------- /DashboardPanelNotice.module: -------------------------------------------------------------------------------- 1 | __('Dashboard Panel: Notice', __FILE__), 17 | 'summary' => __('Display a notice with icon', __FILE__), 18 | 'author' => 'Philipp Daun', 19 | 'version' => '1.5.9', 20 | ] 21 | ); 22 | } 23 | 24 | public function getContent() 25 | { 26 | $icon = wireIconMarkup($this->icon, 'fw'); 27 | $actions = implode('', array_map(function ($url, $label) { 28 | if ($url && $label) { 29 | return "{$label} "; 30 | } 31 | }, $this->actions, array_keys($this->actions))); 32 | if ($this->title) { 33 | return "
    {$icon} {$this->title}  {$this->message}
    {$actions}
    "; 34 | } else { 35 | return "
    {$icon} {$this->message}
    {$actions}
    "; 36 | } 37 | } 38 | 39 | public function getClassNames() 40 | { 41 | return [ 42 | "notice-status--{$this->status}", 43 | ]; 44 | } 45 | 46 | public function setup() 47 | { 48 | parent::setup(); 49 | $this->title = $this->options['title'] ?? ''; 50 | $this->icon = $this->options['icon'] ?? ''; 51 | $this->message = $this->data['message'] ?? ''; 52 | $this->status = $this->data['status'] ?? 'notice'; 53 | $this->actions = $this->data['actions'] ?? []; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DashboardPanelNumber.css: -------------------------------------------------------------------------------- 1 | :root{--dashboard-panel-number-fontsize-number:2.5em;--dashboard-panel-number-fontweight-number:bold;--dashboard-panel-number-color-trend-up:#42af7b;--dashboard-panel-number-color-trend-down:#e34e42}.DashboardPanelNumber .uk-card-body{justify-content:stretch;display:flex}.DashboardPanelNumber__content{text-align:center;justify-content:center;align-items:center;width:100%;display:flex}.DashboardPanelNumber__content dl{margin:10px 0 15px}.DashboardPanelNumber__content dt,.DashboardPanelNumber__content p{font-size:var(--dashboard-panel-number-fontsize-number);font-weight:var(--dashboard-panel-number-fontweight-number);line-height:1;padding:0!important}.DashboardPanelNumber__content dd{color:var(--dashboard-color-text-light);font-weight:400;border:none!important;padding:.25em 0 0!important}.DashboardPanelNumber__content .fa{margin-right:-1.3rem;font-weight:400;display:none;font-size:1.3rem!important}.DashboardPanelNumber__content[data-trend=up] .fa{color:var(--dashboard-panel-number-color-trend-up);display:inline-block;-ms-transform:rotate(-45deg);transform:rotate(-45deg)}.DashboardPanelNumber__content[data-trend=down] .fa{color:var(--dashboard-panel-number-color-trend-down);display:inline-block;-ms-transform:rotate(45deg);transform:rotate(45deg)} -------------------------------------------------------------------------------- /DashboardPanelNumber.module: -------------------------------------------------------------------------------- 1 | __('Dashboard Panel: Number', __FILE__), 17 | 'summary' => __('Display a single number with trend indicator', __FILE__), 18 | 'author' => 'Philipp Daun', 19 | 'version' => '1.5.9', 20 | ] 21 | ); 22 | } 23 | 24 | public function getIcon() 25 | { 26 | return 'bar-chart'; 27 | } 28 | 29 | public function getContent() 30 | { 31 | return $this->view('panels/number', [ 32 | 'number' => $this->number, 33 | 'detail' => $this->detail, 34 | 'trend' => $this->trend, 35 | ]); 36 | } 37 | 38 | public function setup() 39 | { 40 | parent::setup(); 41 | $this->locale = $this->data['locale'] ?? setlocale(LC_ALL, 0); 42 | $this->detail = $this->data['detail'] ?? ''; 43 | $this->number = $this->data['number'] ?? null; 44 | $this->trend = $this->data['trend'] ?? null; 45 | if (is_int($this->number) || is_float($this->number)) { 46 | $this->number = $this->formatNumber($this->number); 47 | } 48 | } 49 | 50 | private function formatNumber($number) 51 | { 52 | $formatted = $number; 53 | 54 | try { 55 | $style = NumberFormatter::DECIMAL; 56 | $formatter = new NumberFormatter($this->locale, $style); 57 | $formatted = $formatter->format($number); 58 | } catch (\Throwable $th) { 59 | $this->setTemporaryLocale(); 60 | $locale = localeconv(); 61 | $decimals = $this->getNumberOfDecimals($number); 62 | $formatted = number_format($number, $decimals, $locale['decimal_point'], $locale['thousands_sep']); 63 | $this->restoreLocales(); 64 | } 65 | 66 | return $formatted; 67 | } 68 | 69 | private function getNumberOfDecimals($value) 70 | { 71 | if ((int) $value == $value) { 72 | return 0; 73 | } elseif (!is_numeric($value)) { 74 | return 0; 75 | } 76 | $str = rtrim(number_format($value, 14 - log10($value)), '0'); 77 | 78 | return strlen($str) - strrpos($str, '.') - 1; 79 | } 80 | 81 | private function setTemporaryLocale() 82 | { 83 | if ($this->locale) { 84 | $this->originalLocales = explode(';', setlocale(LC_ALL, 0)); 85 | setlocale(LC_ALL, $this->locale); 86 | } 87 | } 88 | 89 | private function restoreLocales() 90 | { 91 | if ($this->locale && $this->originalLocales) { 92 | foreach ($this->originalLocales as $localeSetting) { 93 | if (strpos($localeSetting, '=') !== false) { 94 | list($category, $locale) = explode('=', $localeSetting); 95 | $category = (int) $category; 96 | } else { 97 | $category = LC_ALL; 98 | $locale = $localeSetting; 99 | } 100 | setlocale($category, $locale); 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /DashboardPanelPageList.css: -------------------------------------------------------------------------------- 1 | .AdminThemeUikit .DashboardPanelPageList .uk-card-body{padding:0}.AdminThemeReno .DashboardPanelPageList .uk-card-body{padding-top:0;padding-bottom:0}.DashboardPanelPageList .PageList{margin-bottom:-1px;margin-top:-5px!important}.AdminThemeUikit .DashboardPanelPageList .PageList,.AdminThemeReno .DashboardPanelPageList .PageList{margin-top:0!important}.DashboardPanelPageList .PageListRoot>.PageListLoading{margin:30px 15px;display:block}.AdminThemeUikit .DashboardPanelPageList .PageListRootHidden>.PageList>.PageListItem,.AdminThemeUikit .DashboardPanelPageList .PageListRoot>.PageList>.PageListItem{padding-left:8px!important} -------------------------------------------------------------------------------- /DashboardPanelPageList.js: -------------------------------------------------------------------------------- 1 | !function(){var a={pageList:".PageListContainerPage, .PageListContainerRoot",editLink:".PageListActionEdit a, .PageListActionNew a",viewLink:".PageListActionView a"};function t(a,t,e){a.on("mouseenter",t,function(a){"blank"===e&&$(a.target).attr("target","_blank"),"modal"===e&&($(a.target).addClass("pw-modal"),$(a.target).addClass("pw-modal-large"),$(a.target).removeClass("pw-modal-longclick"))})}$(document).on("dashboard:panel(page-list)",function(e,i){var n,o,d,s;o=(n=i.$element).find(a.pageList),d=parseInt(n.data("parent"),10),s=n.data("show-root"),o.ProcessPageList({rootPageID:d,showRootPage:s}),setTimeout(function(){var e,i,o;e=n.find(a.pageList),i=n.data("edit-mode"),o=n.data("view-mode"),e.data("has-events")||(t(e,a.editLink,i),t(e,a.viewLink,o),e.on("pw-modal-closed",a.editLink,function(){n.trigger("reload",{animate:!0})}),e.data("has-events",!0))},1e3)})}(); -------------------------------------------------------------------------------- /DashboardPanelPageList.module: -------------------------------------------------------------------------------- 1 | __('Dashboard Panel: PageList', __FILE__), 16 | 'summary' => __('Display a ProcessPageList widget for any parent', __FILE__), 17 | 'author' => 'Philipp Daun', 18 | 'version' => '1.5.9', 19 | ] 20 | ); 21 | } 22 | 23 | public function getContent() 24 | { 25 | // Disable ajax flag so page list is always rendered as HTML 26 | $ajax = $this->config->ajax; 27 | $this->config->ajax = false; 28 | 29 | // Generate page list 30 | $module = $this->modules->get('ProcessPageList'); 31 | $module->set('id', $this->parentID); 32 | $module->set('showRootPage', $this->showRootPage); 33 | $output = $module->execute(); 34 | 35 | // Modify markup 36 | // Remove script tag (we'll initialize the PageList component manually via JS) 37 | $unique_key = rand(1, 99999); 38 | $container_id = "PageListContainer__{$unique_key}"; 39 | $output = str_replace("id='PageListContainer'", "id='{$container_id}'", $output); 40 | $output = preg_replace('/ 86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # ProcessWire Dashboard 2 | 3 | [![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/daun/processwire-dashboard?color=97aab4&label=version)](https://github.com/daun/processwire-dashboard/releases)  4 | [![ProcessWire version](https://img.shields.io/badge/ProcessWire-%3E%3D%203.0.165-97aab4)](https://processwire.com/download/core/)  5 | [![GitHub License](https://img.shields.io/github/license/daun/processwire-dashboard?color=97aab4)](./LICENSE)  6 | [![GitHub (Pre-)Release Date](https://img.shields.io/github/release-date-pre/daun/processwire-dashboard?label=updated)](https://github.com/daun/processwire-dashboard/releases) 7 | 8 | Display a configurable dashboard in ProcessWire's admin interface. Includes a set of predefined panel types, but can be easily extended to display any content you want. 9 | 10 | ![Dashboard](./images/dashboard.png) 11 | 12 | ## Features 13 | 14 | - **Multiple Panel Types Available**
    15 | The module comes bundled with [multiple panel types](panels.md) that should cover 80% of use cases. 16 | 17 | - **Easy to Extend**
    18 | Creating your own panels is easy: you can either [render a template file](panels/template.md) or [create your own panel types](panels/custom.md) by creating a ProcessWire module. 19 | 20 | - **Highly Configurable**
    21 | You're free to [customize panels](getting-started.md#configuring-panels) in their size, position, title and layout. Panels can be [grouped and nested](panels/groups.md) to create whatever layout you need. 22 | 23 | - **Configuration as Code**
    24 | All configuration is done as code. This meets two objectives: the dashboard config can be version-controlled and sensitive credentials are never stored in the database. You are free to supply the data any way you want, preferably using environment variables. 25 | 26 | ## Contact & Support 27 | 28 | - Create a [GitHub issue](https://github.com/daun/processwire-dashboard/issues) for bug reports and feature requests 29 | - Visit the [ProcessWire support forum thread](https://processwire.com/talk/topic/22847-processwire-dashboard/) to ask questions 30 | - Add a [star on GitHub](https://github.com/daun/processwire-dashboard) to support the project 31 | 32 | ## License 33 | 34 | This project is licensed under the [GPL-3.0 license](https://github.com/daun/processwire-dashboard/blob/master/LICENSE). 35 | 36 | Copyright (c) Philipp Daun ([@daun](https://github.com/daun/)) 37 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. 4 | 5 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 6 | 7 | You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 8 | 9 | (See included LICENSE file for full license text.) 10 | -------------------------------------------------------------------------------- /docs/panels.md: -------------------------------------------------------------------------------- 1 | # Panels 2 | 3 | The module comes bundled with a set of pre-defined panel types. 4 | 5 | Click the images below for details about each panel type. 6 | 7 | | Image | Panel | Description | 8 | | ----------------------------------------------------------------------------- | ------------ | --------------------------------- | 9 | | [![Chart](./images/chart.png ':size=120')](/panels/chart.md) | `chart` | Chart.js chart | 10 | | [![Collection](./images/collection.png ':size=120')](/panels/collection.md) | `collection` | List of pages in a table | 11 | | [![Notice](./images/notice.png ':size=120')](/panels/notice.md) | `notice` | Notification-style message | 12 | | [![Number](./images/number.png ':size=120')](/panels/number.md) | `number` | Large number with trend indicator | 13 | | [![PageList](./images/page-list.png ':size=120')](/panels/page-list.md) | `page-list` | ProcessPageList widget | 14 | | [![Shortcuts](./images/shortcuts-grid.png ':size=120')](/panels/shortcuts.md) | `shortcuts` | List of links with icons | 15 | | [![Template](./images/template.png ':size=120')](/panels/template.md) | `template` | Render file in template folder | 16 | | [![Add New Page](./images/add-new.png ':size=120')](/panels/add-new.md) | `add-new` | Add new page | 17 | -------------------------------------------------------------------------------- /docs/panels/add-new.md: -------------------------------------------------------------------------------- 1 | 2 | # Panel Type: Add New Page 3 | 4 | Display a list of shortcuts for adding new pages. 5 | 6 | The list of templates is derived from ProcessWire's core Add-New-Page dropdown. 7 | 8 | ![Shortcuts](../images/add-new.png ':size=400') 9 | 10 | ## Options 11 | 12 | Required parameters are marked with an asterisk `*` 13 | 14 | | Parameter | Type | Default | Description | 15 | | --------- | -------- | ------- | ---------------------------------------------- | 16 | | `display` | `string` | `list` | How to display the links: `list` or `dropdown` | 17 | 18 | ## Example 19 | 20 | ```php 21 | $panels->add([ 22 | 'panel' => 'add-new', 23 | 'title' => 'Add New Page', 24 | 'data' => [ 25 | 'display' => 'list' 26 | ] 27 | ]); 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/panels/chart.md: -------------------------------------------------------------------------------- 1 | # Panel Type: Chart 2 | 3 | Display a chart using [Chart.js](https://www.chartjs.org/). 4 | 5 | ![Chart](../images/chart.png ':size=400') 6 | 7 | The panel uses Chart.js v2 and could do with an update to v3 or v4. The maintainer 8 | currently lacks the time to tackle this, but a pull request is more than welcome. 9 | 10 | ## Options 11 | 12 | Required parameters are marked with an asterisk `*` 13 | 14 | | Parameter | Type | Default | Description | 15 | | ------------- | ------- | ------- | ------------------------------ | 16 | | **`chart *`** | `array` | `[]` | Chart.js configuration options | 17 | 18 | ## Example 19 | 20 | ```php 21 | $panels->add([ 22 | 'panel' => 'chart', 23 | 'title' => 'Chart', 24 | 'data' => [ 25 | 'chart' => [ 26 | 'type' => 'line', 27 | 'data' => [ 28 | 'labels' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'], 29 | 'datasets' => [ 30 | [ 31 | 'label' => 'Lorem ipsum', 32 | 'data' => [7, 10, 8, 12, 4, 6, 3] 33 | ], 34 | [ 35 | 'label' => 'Dolor sit amet', 36 | 'data' => [5, 6, 7, 8, 6, 8, 14] 37 | ] 38 | ] 39 | ], 40 | 'options' => [ 41 | 'aspectRatio' => 2.5, 42 | 'scales' => [ 43 | 'xAxes' => [ 44 | ['gridLines' => ['display' => false]] 45 | ] 46 | ] 47 | ] 48 | ] 49 | ] 50 | ]); 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/panels/collection.md: -------------------------------------------------------------------------------- 1 | # Panel Type: Collection 2 | 3 | Display a collection of pages in a table. Supply either a PageArray or a selector string. 4 | 5 | ![Collection](../images/collection.png ':size=400') 6 | 7 | ## Options 8 | 9 | Required parameters are marked with an asterisk `*` 10 | 11 | | Parameter | Type | Default | Description | 12 | | ------------------ | --------------------- | -------------- | ------------------------------------------------------------------------------- | 13 | | **`collection *`** | `PageArray`, `string` | | Collection of pages to show (or selector string) | 14 | | `columns` | `array` | `title`, `url` | Columns to display | 15 | | `sortable` | `bool` | `false` | Make table columns sortable? | 16 | | `actions` | `array`, `bool` | `view`, `edit` | Actions to allow: `view`, `edit`, `trash` (`false` to disable) | 17 | | `editMode` | `string` | `blank` | How to open edit links (`none` for same window, `blank` for new tab or `modal`) | 18 | | `viewMode` | `string` | `blank` | How to open view links (same options as `editMode`) | 19 | | `pagination` | `bool` | `true` | Display pagination links if collection has a `limit` set? | 20 | | `headers` | `bool` | `true` | Display table headers? | 21 | | `emptyMessage` | `string` | — | Placeholder to show if the collection is empty, i.e. no pages were found | 22 | | `dateFormat` | `string` | `relative` | Format to use for DateTime columns | 23 | | `maxImageNum` | `int` | `1` | Number of thumbnails to show for image columns | 24 | 25 | ## Example 26 | 27 | ```php 28 | $panels->add([ 29 | 'panel' => 'collection', 30 | 'title' => 'Pages', 31 | 'data' => [ 32 | 'collection' => 'template=info, limit=10', 33 | 'sortable' => true, 34 | 'columns' => [ 35 | 'title', 36 | 'url' => 'URL', 37 | 'modified' => 'Modified' 38 | ] 39 | ] 40 | ]); 41 | ``` 42 | 43 | ## Column headers 44 | 45 | By default, the panel will guess the column header from the field label. If your 46 | `title` field is called `Title`, this will be used as column header. 47 | 48 | ```php 49 | 'columns' => [ 50 | 'title' 51 | ] 52 | ``` 53 | 54 | To override a default header, use the array key as column name and the value as column header. 55 | 56 | ```php 57 | 'columns' => [ 58 | 'title' => 'El título' 59 | ] 60 | ``` 61 | 62 | To display no header for that column, pass an empty string. 63 | 64 | ```php 65 | 'columns' => [ 66 | 'title' => '' 67 | ] 68 | ``` 69 | 70 | To display an icon as header, pass a FontAwesome icon code (including the `fa-` prefix). 71 | 72 | ```php 73 | 'columns' => [ 74 | 'thumbnail' => 'fa-eye' 75 | ] 76 | ``` 77 | 78 | ## Complex markup 79 | 80 | Columns support dot syntax and curly brackets to access sub-fields: 81 | 82 | ```php 83 | 'columns' => [ 84 | 'category.title' => 'Category', 85 | 'createdUser.name' => 'Created by', 86 | 'On {location.street} in {location.city}' => 'Location' 87 | ] 88 | ``` 89 | 90 | ## Image columns 91 | 92 | Pass the name of any image field as the column key to display thumbnails. Only the first image is shown by default, but you can change the number of images shown by setting the `maxImageNum` option. 93 | 94 | ```php 95 | [ 96 | 'columns' => [ 97 | 'images' 98 | ], 99 | 'maxImageNum' => 4 100 | ] 101 | ``` 102 | 103 | ## Page icon columns 104 | 105 | Add the column `page_icon` to display page icons in their own column. 106 | 107 | ```php 108 | 'columns' => [ 109 | 'page_icon' => '' 110 | ] 111 | ``` 112 | 113 | ## Reload after modal edits 114 | 115 | When setting `editMode` to `modal`, the panel will reload its contents when the modal closes. Any changes made to the page will be visible immediately. 116 | -------------------------------------------------------------------------------- /docs/panels/custom.md: -------------------------------------------------------------------------------- 1 | 2 | # Custom Panels 3 | 4 | While the easiest way to create custom panels is simply [rendering a template file](/panels/template.md), the module aims to be easily extendable with custom panel types. 5 | 6 | To create custom panels, simply extend the `DashboardPanel` base class. 7 | 8 | See [DashboardPanelHelloWorld](https://github.com/daun/processwire-dashboard/blob/master/DashboardPanelHelloWorld.module) or take a look at the [Third-party panels](/panels/third-party.md) for example implementations. 9 | 10 | ## Naming 11 | 12 | Class names in `PascalCase` are translated to panel names in `kebab-case`. 13 | 14 | - Module name: `DashboardPanelHelloWorld` 15 | - Panel name: `hello-world` 16 | 17 | ## Class interface 18 | 19 | Every panel module **must** implement the `getContent()` method that returns the rendered markup for the panel body. Everything else is optional. 20 | 21 | - `setup()`: called before rendering to fetch data, setup variables, etc. 22 | - `getContent()`: returns the panel's body markup (required, string) 23 | - `getTitle()`: returns the panel's title (string) 24 | - `getIcon()`: returns an icon code to display next to the title (string) 25 | - `getFooter()`: returns the rendered markup for the panel footer (string) 26 | - `getClassNames()`: returns additional class names for the panel div (array) 27 | - `getAttributes()`: returns HTML attributes for the panel card (array of `attr => value`) 28 | - `getStylesheets()`: returns stylesheets to load (array of filenames or URLs) 29 | - `getScripts()`: returns scripts to load (array of filenames or URLs) 30 | - `getInterval()`: returns the interval at which the panel is reloaded via AJAX (integer, milliseconds) 31 | 32 | ## Accessing config & data 33 | 34 | Every module derived from the `DashboardPanel` base class has a few properties populated automatically: 35 | 36 | - `$this->options`: Global panel options like title, icon, etc (array) 37 | - `$this->data`: Panel-specific configuration (array) 38 | - `$this->size`: Panel size (string, sanitized to one of allowed values) 39 | - `$this->style`: Style options of this panel instance (array) 40 | 41 | ## Module assets 42 | 43 | Module assets will be included automatically as long as they're named accordingly (`DashboardPanelHelloWorld.css` and `DashboardPanelHelloWorld.js` respectively). 44 | 45 | ## Markup 46 | 47 | Panels are generated as UiKit cards with a header, body and footer. This is a simplified version of what is rendered for each panel: 48 | 49 | ```html 50 |
    51 |
    52 |

    53 | Hello World 54 |

    55 |
    56 |
    57 |

    Nothing to see here

    58 |
    59 | 62 |
    63 | ``` 64 | 65 | ## Styling 66 | 67 | Namespace your CSS to make sure you're targeting your custom panel type only. 68 | 69 | ```css 70 | .DashboardPanelHelloWorld .uk-card-body { 71 | font-style: italic; 72 | } 73 | ``` 74 | 75 | ## Initialize panels with JS 76 | 77 | If you need to run JavaScript to initialize a panel, you can listen for the `dashboard:panel` event that is fired whenever a panel is loaded (or reloaded via AJAX). The event receives a data object with two relevant properties: `$element` is the panel DOM element as a jQuery object and `panel` is the panel type. 78 | 79 | ```js 80 | /* Listen to ready event of new 'hello-world' panels */ 81 | $(document).on('dashboard:panel(hello-world)', (event, { $element }) => { 82 | /* Initialize the panel */ 83 | }); 84 | ``` 85 | 86 | See [DashboardPanelHelloWorld.js](https://github.com/daun/processwire-dashboard/blob/master/src/DashboardPanelHelloWorld.js) for an example implementation. 87 | 88 | ## Reload panels 89 | 90 | To reload a panel via AJAX, trigger a `reload` event on the panel object. 91 | 92 | ```js 93 | /* Instant reload */ 94 | $panel.trigger('reload'); 95 | 96 | /* Fade transition */ 97 | $panel.trigger('reload', { animate: true }); 98 | ``` 99 | 100 | ### Post params 101 | 102 | To pass data from JS to the PHP panels class, pass a `params` option that will be sent as POST data. 103 | 104 | ```js 105 | /* Send data from JS */ 106 | $panel.trigger('reload', { params: { page: 4 } }); 107 | ``` 108 | 109 | ```php 110 | /* Access data from PHP */ 111 | $page = $_POST['page'] ?? 1; 112 | ``` 113 | 114 | ## Markup helpers 115 | 116 | The panel class has a few helpers to render common markup categories. 117 | 118 | ### Tables 119 | 120 | `DashboardPanel::renderTable($rows, $options = [])` 121 | 122 | Render a table using ProcessWire's built-in `MarkupAdminDataTable` module. Returns HTML. 123 | 124 | If the `header` option is true, the first row will be displayed as a header row. Same for `footer`. 125 | 126 | ```php 127 | /* Usage with default values */ 128 | 129 | $rows = [ 130 | ['Title', 'Date'], 131 | ['Lorem ipsum', '1.12.2019'], 132 | ]; 133 | 134 | $html = $this->renderTable($rows, [ 135 | 'header' => false, 136 | 'footer' => false, 137 | 'entities' => false, 138 | 'sortable' => false, 139 | 'class' => '', 140 | ]); 141 | ``` 142 | 143 | ### Icons 144 | 145 | `DashboardPanel::renderIcon($icon)` 146 | 147 | Render a FontAwesome icon in fixed width. 148 | 149 | ```php 150 | $icon = $this->renderIcon('star'); 151 | ``` 152 | 153 | ### Views 154 | 155 | `DashboardPanel::view($view, $variables = [])` 156 | 157 | Render a template file in a `views` sub-directory relative to the module file. Passes all ProcessWire API variables as well as user-supplied variables. 158 | 159 | ```php 160 | /* Render ./views/content.php and pass a $title var */ 161 | 162 | $markup = $this->view('content', [ 163 | 'title' => 'Lorem ipsum' 164 | ]); 165 | ``` 166 | -------------------------------------------------------------------------------- /docs/panels/groups.md: -------------------------------------------------------------------------------- 1 | 2 | # Panel Groups 3 | 4 | Panels can be displayed in a nested grid by creating groups. Each group can have a title and add extra margin below. 5 | 6 | ```php 7 | /* Create a group */ 8 | $group = $panels->createGroup([ 9 | 'size' => 'normal', 10 | 'title' => 'Notifications', 11 | 'align' => 'top', 12 | 'margin' => true, 13 | ]); 14 | $panels->add($group); 15 | 16 | /* Nest panels below */ 17 | foreach (getNotifications() as $message) { 18 | $group->add([ 19 | 'panel' => 'notice', 20 | 'size' => 'full', 21 | 'data' => ['message' => $message], 22 | ]); 23 | } 24 | ``` 25 | 26 | ## Options 27 | 28 | | Parameter | Type | Default | Description | 29 | | --------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | 30 | | `title` | `string` | | Heading displayed above the group | 31 | | `margin` | `bool` | `false` | Add extra margin below the group | 32 | | `align` | `string` | `fill` | Control vertical alignment of the panels inside the group: `top`, `bottom`, `center`, `distribute` (add space between) or `fill` (stretch to fill space) | 33 | 34 | ## Example configuration 35 | 36 | See the example below for a common group setup and the necessary code. 37 | 38 | ![Groups](../images/groups.png) 39 | 40 | ```php 41 | /* Create panel group: analytics */ 42 | 43 | $analytics = $panels->createGroup([ 44 | 'size' => 'normal', 45 | 'title' => 'Visitor Stats', 46 | 'margin' => true, 47 | ]); 48 | $panels->add($analytics); 49 | 50 | /* Add chart panels to group */ 51 | 52 | $analytics->add([ 53 | 'panel' => 'chart', 54 | 'title' => 'Origin', 55 | 'data' => [ /* */ ], 56 | ]); 57 | $analytics->add([ 58 | 'panel' => 'chart', 59 | 'title' => 'Retention', 60 | 'data' => [ /* */ ], 61 | ]); 62 | 63 | /* Create panel group: notices */ 64 | 65 | $notices = $panels->createGroup([ 66 | 'size' => 'normal', 67 | 'title' => 'Notifications', 68 | 'align' => 'top', 69 | 'margin' => true, 70 | ]); 71 | $panels->add($notices); 72 | 73 | /* Add notice panels to group */ 74 | 75 | $notices->add([ 76 | 'panel' => 'notice', 77 | 'size' => 'full', 78 | 'title' => 'Good job!', 79 | 'data' => [ /* */ ], 80 | ]); 81 | 82 | $notices->add([ 83 | 'panel' => 'notice', 84 | 'size' => 'full', 85 | 'title' => 'Hint:', 86 | 'data' => [ /* */ ], 87 | ]); 88 | 89 | $notices->add([ 90 | 'panel' => 'notice', 91 | 'size' => 'full', 92 | 'title' => 'Welcome back.', 93 | 'data' => [ /* */ ], 94 | ]); 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/panels/notice.md: -------------------------------------------------------------------------------- 1 | # Panel Type: Notice 2 | 3 | Display a notice with icon and actions. If set, the panel's title will be displayed inline and in bold instead of inside a panel header. 4 | 5 | ![Notice](../images/notice.png ':size=400') 6 | 7 | ## Options 8 | 9 | Required parameters are marked with an asterisk `*` 10 | 11 | | Parameter | Type | Default | Description | 12 | | --------------- | -------- | -------- | ------------------------------------------------------------------ | 13 | | **`message *`** | `string` | | Notice to display | 14 | | `status` | `string` | `notice` | Status of the notice: `notice`, `success`, `warning` or `error` | 15 | | `actions` | `array` | `[]` | Additional links to display (array of format `['Label' => 'url']`) | 16 | 17 | ## Example 18 | 19 | ```php 20 | $panels->add([ 21 | 'panel' => 'notice', 22 | 'title' => 'Welcome', 23 | 'icon' => 'inbox', 24 | 'data' => [ 25 | 'message' => 'You have 15 new messages.', 26 | 'status' => 'success', 27 | 'actions' => [ 28 | 'See all' => '/inbox/' 29 | ] 30 | ] 31 | ]); 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/panels/number.md: -------------------------------------------------------------------------------- 1 | # Panel Type: Number 2 | 3 | Display a large number with trend indicator. 4 | 5 | ![Number](../images/number.png ':size=300') 6 | 7 | ## Options 8 | 9 | Required parameters are marked with an asterisk `*` 10 | 11 | | Parameter | Type | Default | Description | 12 | | -------------- | ------------------------ | ------------- | ------------------------------------------------------- | 13 | | **`number *`** | `int`, `float`, `string` | | The number to display | 14 | | `detail` | `string` | | Additional information to display below | 15 | | `trend` | `string` | | Trend to indicate with green/red arrows: `up` or `down` | 16 | | `locale` | `string` | server locale | Locale to use for formatting integers or floats | 17 | 18 | ## Example 19 | 20 | ```php 21 | $panels->add([ 22 | 'panel' => 'number', 23 | 'title' => 'Number', 24 | 'data' => [ 25 | 'number' => 484, 26 | 'detail' => 'up 5% from last week', 27 | 'trend' => 'up' 28 | ] 29 | ]); 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/panels/page-list.md: -------------------------------------------------------------------------------- 1 | # Panel Type: PageList 2 | 3 | Display a ProcessPageList widget. 4 | 5 | ![PageList](../images/page-list.png ':size=400') 6 | 7 | ## Options 8 | 9 | | Parameter | Type | Default | Description | 10 | | -------------- | ----------------------- | -------- | ------------------------------------------------------------------------------- | 11 | | `parent` | `Page`, `int`, `string` | Homepage | Root page to render the page list for (Page, ID or selector) | 12 | | `showRootPage` | `bool` | `true` | Include the root page in the output? | 13 | | `editMode` | `string` | `blank` | How to open edit links (`none` for same window, `blank` for new tab or `modal`) | 14 | | `viewMode` | `string` | `blank` | How to open view links (same options as `editMode`) | 15 | 16 | ## Example 17 | 18 | ```php 19 | $panels->add([ 20 | 'panel' => 'page-list', 21 | 'title' => 'Page list', 22 | 'data' => [ 23 | 'parent' => 'template=info', 24 | 'showRootPage' => true, 25 | 'editMode' => 'modal' 26 | ] 27 | ]); 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/panels/shortcuts.md: -------------------------------------------------------------------------------- 1 | 2 | # Panel Type: Shortcuts 3 | 4 | Display a list of shortcuts as links with icons. In list view, the page summary is displayed next to the title. 5 | 6 | ![Shortcuts](../images/shortcuts-comparison.png ':size=600') 7 | 8 | ## Options 9 | 10 | Required parameters are marked with an asterisk `*` 11 | 12 | | Parameter | Type | Default | Description | 13 | | ----------------- | -------- | ------------ | ---------------------------------------------------------------- | 14 | | **`shortcuts *`** | `array` | | Shortcuts to display: Page objects, page IDs, selectors and URLs | 15 | | `display` | `string` | `grid` | How to display the shortcuts: `grid` or `list` | 16 | | `summaries` | `bool` | `true` | Whether to display summaries | 17 | | `fallbackIcon` | `string` | `bookmark-o` | Icon to use if page doesn't have one | 18 | | `icon` | `string` | none | Icon to uniformly use for **all** pages | 19 | | `checkAccess` | `bool` | `true` | Only show pages the user has access to | 20 | 21 | See the example below on how to customize the icon, title, summary and URL for each shortcut. 22 | 23 | ## Example 24 | 25 | ```php 26 | $panels->add([ 27 | 'panel' => 'shortcuts', 28 | 'title' => 'Shortcuts', 29 | 'data' => [ 30 | 'shortcuts' => [ 31 | 1020, // Page ID 32 | $this->pages->get(1132), // Page 33 | 'template=news-item', // Selector 34 | 'Backups' => '/backup/', // URL 35 | 'Updates' => 1020, // Override title 36 | [304, 'user'], // Override icon 37 | [304, 'user', 'Lorem ipsum'], // Set summary 38 | ], 39 | 'fallbackIcon' => 'star-o', 40 | 'checkAccess' => false 41 | ] 42 | ]); 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/panels/tabs.md: -------------------------------------------------------------------------------- 1 | 2 | # Tabs 3 | 4 | Panels can be grouped in tabs, hiding them until the tab is clicked. 5 | 6 | ```php 7 | /* Create tab and add panels */ 8 | $tab = $panels->createTab(['title' => 'Pages']); 9 | $tab->add(['panel' => 'page-list', 'data' => []]); 10 | $panels->add($tab); 11 | 12 | /* Repeat for additional tabs */ 13 | $tab = $panels->createTab(['title' => 'Analytics']); 14 | $tab->add(['panel' => 'chart', 'data' => []]); 15 | $panels->add($tab); 16 | ``` 17 | 18 | ## Options 19 | 20 | Required parameters are marked with an asterisk `*` 21 | 22 | | Parameter | Type | Default | Description | 23 | | ------------- | -------- | ------- | ------------------- | 24 | | **`title *`** | `string` | | Clickable tab label | 25 | -------------------------------------------------------------------------------- /docs/panels/template.md: -------------------------------------------------------------------------------- 1 | 2 | # Panel Type: Template 3 | 4 | Display the output of any file in your template folder. The file will receive all API variables and any additional view variables you specify. 5 | 6 | ![Template](../images/template.png ':size=400') 7 | 8 | ## Options 9 | 10 | Required parameters are marked with an asterisk `*` 11 | 12 | | Parameter | Type | Default | Description | 13 | | ---------------- | -------- | ------- | -------------------------------------------------------------------------- | 14 | | **`template *`** | `string` | | Template file name, relative to `/site/templates/` and including extension | 15 | | `variables` | `array` | `[]` | Data to pass into the template | 16 | 17 | ## Example 18 | 19 | The following configuration will include `/site/templates/dashboard/example.php`. 20 | 21 | ```php 22 | $panels->add([ 23 | 'panel' => 'template', 24 | 'title' => 'Template file', 25 | 'data' => [ 26 | 'template' => 'dashboard/example.php', 27 | 'variables' => [ 28 | 'text' => 'Lorem ipsum dolor ...' 29 | ] 30 | ] 31 | ]); 32 | ``` 33 | 34 | The specified template file will be rendered like any other ProcessWire template. 35 | 36 | ```php 37 | /* site/templates/dashboard/example.php */ 38 | 39 |

    40 | ``` 41 | 42 | ## CSS and JS files 43 | 44 | Scripts and stylesheets that match the template file name will be included automatically. For the example above, this would mean that `/site/templates/dashboard/example.css` and `*.js` will be included as well (if they exist). 45 | 46 | ## Namespacing 47 | 48 | Each template panel has a `data-file` attribute that contains the exact filename of the template being rendered. You can use this attribute to namespace any custom styles or track down the DOM element via JS. 49 | 50 | ```css 51 | /* Style panel by its template file */ 52 | 53 | .DashboardPanelTemplate[data-file="dashboard/example.php"] { 54 | color: red; 55 | } 56 | ``` 57 | 58 | ```js 59 | /* Find a panel by its template file and initialize it */ 60 | 61 | $(document).on('dashboard:panel(template)', (event, { $element }) => { 62 | if ($element.data('file') === 'dashboard/example.php') { 63 | /* Initialize the panel */ 64 | } 65 | }); 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/panels/third-party.md: -------------------------------------------------------------------------------- 1 | # Third-Party Panels 2 | 3 | If you created a separate dashboard panel, feel free to create a Pull Request and 4 | add it here. 5 | 6 | - [Random Image](https://github.com/daun/processwire-dashboard-panel-random-image): Display a random image 7 | -------------------------------------------------------------------------------- /docs/requirements.md: -------------------------------------------------------------------------------- 1 | # Requirements 2 | 3 | - **PHP**: 7.0 or newer 4 | - **ProcessWire**: 3.0.165 5 | - **Admin theme**: UiKit recommended 6 | - The basics should work on most themes, but feature parity is not a goal 7 | - **Browsers**: those with proper [CSS grid support](https://caniuse.com/#feat=css-grid) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "processwire-dashboard", 3 | "version": "1.5.9", 4 | "description": "Configurable dashboard for ProcessWire's admin interface", 5 | "scripts": { 6 | "dev": "parcel src/*.js src/*.css --dist-dir . --no-source-maps --no-hmr", 7 | "build": "parcel build src/*.js src/*.css --dist-dir . --no-source-maps", 8 | "bump": "bump package.json package-lock.json VERSION *.module --commit \"Release v%s\" --tag \"v\"", 9 | "docs": "docsify serve docs" 10 | }, 11 | "author": "Philipp Daun", 12 | "license": "GPL-3.0", 13 | "private": true, 14 | "devDependencies": { 15 | "@babel/core": "^7.17.10", 16 | "parcel": "^2.5.0", 17 | "postcss": "^8.4.13", 18 | "postcss-nested": "^6.2.0" 19 | }, 20 | "dependencies": { 21 | "lodash": "^4.17.21", 22 | "version-bump-prompt": "^6.1.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([__DIR__]) 9 | ->withSkip([__DIR__ . '/vendor']) 10 | ->withPhpSets(php83: true) 11 | ->withRules([ 12 | // TypedPropertyFromStrictConstructorRector::class 13 | ]) 14 | // here we can define, what prepared sets of rules will be applied 15 | ->withPreparedSets( 16 | // deadCode: true, 17 | // codeQuality: true 18 | ); 19 | -------------------------------------------------------------------------------- /src/Dashboard.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | 3 | :root { 4 | /* Grid setup */ 5 | --dashboard-grid-cols: 12; 6 | --dashboard-grid-gap: 25px; 7 | --dashboard-grid-gap-large: 40px; 8 | 9 | /* Grid sizes */ 10 | --dashboard-grid-size-micro: 2; 11 | --dashboard-grid-size-mini: 3; 12 | --dashboard-grid-size-small: 4; 13 | --dashboard-grid-size-normal: 6; 14 | --dashboard-grid-size-large: 8; 15 | 16 | /* Colors */ 17 | --dashboard-color-body-bg: #f0f3f7; 18 | --dashboard-color-panel-bg: #fff; 19 | --dashboard-color-separator: #e5e5e5; 20 | 21 | --dashboard-color-text-light: #97aab4; 22 | 23 | --dashboard-color-icon: #97aab4; 24 | 25 | --dashboard-color-button-bg: #f0f3f7; 26 | --dashboard-color-button-border: #d7dadf; 27 | --dashboard-color-button-text: #354b60; 28 | --dashboard-color-button-hover-bg: #6c8dae; 29 | --dashboard-color-button-hover-text: #fff; 30 | 31 | --dashboard-color-th-bg: #fafbfc; 32 | 33 | --dashboard-color-list-hover-bg: #fafbfc; 34 | 35 | --dashboard-color-success: hsl(151, 45%, 47%); 36 | --dashboard-color-success-bg: hsl(151, 45%, 95%); 37 | --dashboard-color-warning: hsl(26, 84%, 51%); 38 | --dashboard-color-warning-bg: hsl(26, 84%, 94%); 39 | --dashboard-color-error: hsl(4, 74%, 57%); 40 | --dashboard-color-error-bg: hsl(4, 74%, 96%); 41 | --dashboard-color-progress: #354b60; 42 | --dashboard-color-progress-bg: hsl(201, 16%, 80%); 43 | 44 | /* Font sizes */ 45 | --dashboard-fontsize-icon: 1.25em; 46 | 47 | /* Panels */ 48 | --dashboard-card-shadow: 0 5px 15px rgba(0,0,0,.08); 49 | --dashboard-card-border-radius: 4px; 50 | --dashboard-card-padding-content-x: 20px; 51 | --dashboard-card-padding-content-y: 20px; 52 | --dashboard-card-padding-header-x: 20px; 53 | --dashboard-card-padding-header-y: 13px; 54 | --dashboard-card-padding-list-y: 10px; 55 | } 56 | 57 | @media (min-width: 768px) { 58 | :root { 59 | --dashboard-grid-gap-large: 75px; 60 | } 61 | } 62 | 63 | body.Dashboard { 64 | background-color: var(--dashboard-color-body-bg); 65 | min-height: 100vh; 66 | } 67 | 68 | .AdminThemeUikit.Dashboard { 69 | #pw-footer { 70 | margin-bottom: 0; 71 | } 72 | } 73 | 74 | .AdminThemeReno.Dashboard, 75 | .AdminThemeDefault.Dashboard { 76 | #main, #breadcrumbs, #headline, #content, #footer { 77 | background-color: var(--dashboard-color-body-bg); 78 | } 79 | } 80 | 81 | .AdminThemeDefault.Dashboard { 82 | #breadcrumbs { 83 | border-color: transparent; 84 | } 85 | } 86 | 87 | /* Remove paragraph margins */ 88 | 89 | .Dashboard__panel p { 90 | &:first-child { 91 | margin-top: 0; 92 | } 93 | &:last-child { 94 | margin-bottom: 0; 95 | } 96 | } 97 | 98 | /* Add UiKit base styles for other admin themes */ 99 | 100 | .uk-card { 101 | background: var(--dashboard-color-panel-bg); 102 | box-shadow: var(--dashboard-card-shadow); 103 | transition: box-shadow .1s ease-in-out; 104 | position: relative; 105 | box-sizing: border-box; 106 | border-radius: var(--dashboard-card-border-radius); 107 | } 108 | 109 | .uk-card-header { 110 | border-bottom: 1px solid var(--dashboard-color-separator); 111 | } 112 | 113 | .uk-card-title { 114 | margin: 0 !important; 115 | line-height: 1; 116 | } 117 | 118 | .uk-card-small .uk-card-body, 119 | .uk-card-small.uk-card-body { 120 | padding: var(--dashboard-card-padding-content-y) var(--dashboard-card-padding-content-x); 121 | } 122 | 123 | .uk-card-small .uk-card-header { 124 | padding: var(--dashboard-card-padding-header-y) var(--dashboard-card-padding-header-x); 125 | } 126 | 127 | .uk-card-small .uk-card-footer { 128 | padding: var(--dashboard-card-padding-header-y) var(--dashboard-card-padding-header-x); 129 | } 130 | 131 | /* Dashboard without headline */ 132 | 133 | .DashboardNoHeadline { 134 | #pw-content-head h1 { 135 | display: none; 136 | } 137 | #pw-content-body { 138 | padding-top: 20px; 139 | } 140 | } 141 | 142 | /* Module info footer */ 143 | 144 | .Dashboard__info { 145 | padding-top: 3em; 146 | a { 147 | color: inherit; 148 | } 149 | } 150 | 151 | /* Get started notice */ 152 | 153 | .Dashboard__getStarted { 154 | display: flex; 155 | flex-direction: column; 156 | justify-content: center; 157 | align-items: center; 158 | text-align: center; 159 | min-height: 80vh; 160 | 161 | p { 162 | margin-bottom: 0; 163 | &:nth-child(2) { 164 | font-size: 1.25em; 165 | font-weight: bold; 166 | } 167 | &:nth-child(3) { 168 | a { 169 | color: inherit; 170 | text-decoration: underline; 171 | } 172 | } 173 | &:nth-child(4) { 174 | margin-top: 2em; 175 | } 176 | } 177 | 178 | .fa { 179 | font-size: 4em; 180 | color: var(--dashboard-color-icon); 181 | } 182 | } 183 | 184 | /* 12-column grid */ 185 | 186 | .Dashboard__grid { 187 | display: grid; 188 | grid-template-columns: repeat(var(--dashboard-grid-cols), 1fr); 189 | grid-gap: var(--dashboard-grid-gap); 190 | align-items: stretch; 191 | } 192 | 193 | .Dashboard__panel { 194 | grid-column: span var(--dashboard-grid-cols); 195 | display: flex; 196 | flex-direction: column; 197 | 198 | /* Reponsive sizes */ 199 | 200 | @media (min-width: 768px) { 201 | grid-column: span var(--dashboard-grid-size-normal); 202 | &[data-size='micro'] { 203 | grid-column: span var(--dashboard-grid-size-micro); 204 | } 205 | &[data-size='mini'] { 206 | grid-column: span var(--dashboard-grid-size-mini); 207 | } 208 | &[data-size='small'] { 209 | grid-column: span var(--dashboard-grid-size-small); 210 | } 211 | &[data-size='large'] { 212 | grid-column: span var(--dashboard-grid-size-large); 213 | } 214 | &[data-size='full'] { 215 | grid-column: span var(--dashboard-grid-cols); 216 | } 217 | } 218 | 219 | /* Vertical alignment */ 220 | 221 | &[data-align='top'] { 222 | align-self: start; 223 | } 224 | &[data-align='bottom'] { 225 | align-self: end; 226 | } 227 | &[data-align='center'] { 228 | align-self: center; 229 | } 230 | } 231 | 232 | /* Tabs */ 233 | 234 | .Dashboard__tabs { 235 | + .Dashboard__grid { 236 | margin-top: var(--dashboard-grid-gap-large); 237 | } 238 | } 239 | 240 | .Dashboard__tabs__list { 241 | display: flex; 242 | flex-wrap: wrap; 243 | list-style: none; 244 | margin: 0 0 var(--dashboard-grid-gap); 245 | padding: 0; 246 | font-size: 1.25rem; 247 | font-weight: bold; 248 | li:not(:first-child) { 249 | margin-left: .5em; 250 | } 251 | li:not(:last-child) { 252 | margin-right: .5em; 253 | } 254 | a { 255 | color: var(--dashboard-color-text-light); 256 | text-decoration: none; 257 | transition: color .2s ease-in-out; 258 | } 259 | li a[aria-current='true'], 260 | a:hover { 261 | color: inherit; 262 | text-decoration: none; 263 | } 264 | } 265 | 266 | .Dashboard__tabs__content { 267 | &[aria-hidden='true'] { 268 | display: none; 269 | } 270 | } 271 | 272 | .Dashboard__tabs[data-cloak] { 273 | .Dashboard__tabs__list li:first-child a { 274 | color: inherit; 275 | } 276 | .Dashboard__tabs__content + .Dashboard__tabs__content { 277 | display: none; 278 | } 279 | } 280 | 281 | .Dashboard__panels > .Dashboard__tabs { 282 | margin-top: var(--dashboard-grid-gap); 283 | } 284 | 285 | /* Groups */ 286 | 287 | .Dashboard__group { 288 | display: flex; 289 | flex-direction: column; 290 | align-items: stretch; 291 | 292 | /* Added margin on bottom */ 293 | &[data-margin='true'] { 294 | margin-bottom: var(--dashboard-grid-gap-large); 295 | } 296 | } 297 | 298 | .Dashboard__group__title { 299 | margin: 0 0 var(--dashboard-grid-gap) 0 !important; 300 | font-size: 1.25rem; 301 | font-weight: bold; 302 | color: inherit; 303 | .fa { 304 | display: none; 305 | } 306 | } 307 | 308 | .Dashboard__group .Dashboard__grid { 309 | flex: 1 0 auto; 310 | align-content: stretch; 311 | 312 | /* Vertical alignment */ 313 | .Dashboard__group[data-align='top'] & { 314 | align-content: start; 315 | } 316 | .Dashboard__group[data-align='bottom'] & { 317 | align-content: end; 318 | } 319 | .Dashboard__group[data-align='center'] & { 320 | align-content: center; 321 | } 322 | .Dashboard__group[data-align='distribute'] & { 323 | align-content: space-between; 324 | } 325 | .Dashboard__group[data-align='fill'] & { 326 | align-content: stretch; 327 | } 328 | } 329 | 330 | 331 | /* Cards */ 332 | 333 | .Dashboard__panel { 334 | min-width: 0; 335 | 336 | &:not(.Dashboard__group) { 337 | overflow: hidden; 338 | } 339 | 340 | .uk-card-title { 341 | font-size: 1rem; 342 | font-weight: bold; 343 | color: inherit; 344 | 345 | /* Icon */ 346 | .fa { 347 | margin-right: .4em; 348 | font-size: 1.15em; 349 | position: relative; 350 | top: .05em; 351 | opacity: .65; 352 | display: none; 353 | 354 | .Dashboard[data-icons='true'] & { 355 | display: inline-block; 356 | } 357 | } 358 | } 359 | 360 | .uk-card-body { 361 | flex-grow: 1; 362 | } 363 | 364 | &[data-style-center-title='true'] { 365 | .uk-card-header { 366 | text-align: center; 367 | } 368 | } 369 | 370 | &[data-style-borders='false'] { 371 | .uk-card-header, 372 | .uk-card-footer { 373 | border: none; 374 | } 375 | } 376 | 377 | &[data-style-padding='false'] { 378 | .uk-card-body { 379 | padding: 0; 380 | } 381 | } 382 | 383 | &[data-style-minimal='true'] { 384 | background: transparent; 385 | box-shadow: none; 386 | border-radius: 0; 387 | .uk-card-header, 388 | .uk-card-footer { 389 | border: none; 390 | } 391 | } 392 | } 393 | 394 | /* Buttons */ 395 | 396 | .AdminThemeUikit { 397 | .DashboardButton--light .ui-button { 398 | background: var(--dashboard-color-button-bg); 399 | color: var(--dashboard-color-button-text); 400 | &.ui-state-hover { 401 | background: var(--dashboard-color-button-hover-bg); 402 | color: var(--dashboard-color-button-hover-text); 403 | } 404 | } 405 | } 406 | 407 | /* Remove margin around tables */ 408 | 409 | .Dashboard__panel { 410 | .uk-card-body > .pw-table-responsive { 411 | margin: -20px; 412 | margin-bottom: -21px; 413 | } 414 | .uk-card-body > .AdminDataTable { 415 | margin-top: -10px; 416 | margin-bottom: 0px; 417 | } 418 | .AdminDataTable { 419 | .AdminThemeUikit & { 420 | th { 421 | background-color: var(--dashboard-color-th-bg); 422 | } 423 | th, td { 424 | &:first-child { 425 | padding-left: 20px; 426 | } 427 | &:last-child { 428 | padding-right: 20px; 429 | } 430 | } 431 | } 432 | } 433 | } 434 | 435 | /* Pagination */ 436 | 437 | .DashboardPagination { 438 | margin: 0; 439 | > * > * { 440 | background: var(--dashboard-color-button-bg); 441 | color: var(--dashboard-color-button-text); 442 | border-color: var(--dashboard-color-button-border); 443 | transition: background 200ms, color 200ms, border 200ms; 444 | } 445 | > * > :hover, 446 | > * > :focus { 447 | background: var(--dashboard-color-button-hover-bg); 448 | color: var(--dashboard-color-button-hover-text); 449 | border-color: var(--dashboard-color-button-hover-bg); 450 | } 451 | > .uk-active > *, 452 | > .uk-active > * > * { 453 | background: var(--dashboard-color-button-hover-bg); 454 | color: var(--dashboard-color-button-hover-text); 455 | border-color: var(--dashboard-color-button-hover-bg); 456 | } 457 | } 458 | 459 | .DashboardPaginationSeparator { 460 | pointer-events: none; 461 | } 462 | 463 | .DashboardFooterButtons { 464 | margin: -.5em; 465 | display: flex; 466 | flex-wrap: wrap; 467 | > * { 468 | display: inline-block; 469 | margin: .5em; 470 | } 471 | .ui-button { 472 | margin-right: 0; 473 | } 474 | } 475 | 476 | .DashboardFooterPagination { 477 | display: flex; 478 | flex-wrap: wrap; 479 | align-items: baseline; 480 | justify-content: flex-end; 481 | margin-bottom: -.5em; 482 | margin-right: -1em; 483 | margin-left: auto; 484 | padding-left: 1em; 485 | > * { 486 | margin-bottom: .5em; 487 | margin-right: 1em; 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /src/Dashboard.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | 3 | import setupTooltips from './lib/tooltips'; 4 | 5 | class Dashboard { 6 | constructor() { 7 | this.selectors = { 8 | panel: '[data-dashboard-panel]', 9 | tabs: '[data-dashboard-tabs]', 10 | tabLink: '[data-dashboard-tab]', 11 | }; 12 | } 13 | 14 | init() { 15 | this.url = document.location.pathname + document.location.search; 16 | this.$panels = $(this.selectors.panel); 17 | this.$tabs = $(this.selectors.tabs); 18 | 19 | this.$panels.each((_, panel) => { 20 | this.setupPanel($(panel)); 21 | }); 22 | 23 | this.$tabs.each((_, tabs) => { 24 | this.setupTabs($(tabs)); 25 | }); 26 | 27 | this.setupReloadEvents(); 28 | this.setupAutoReload(); 29 | 30 | this.triggerDashboardReadyEvent(); 31 | } 32 | 33 | getPanelByKey(key) { 34 | return this.$panels.filter(`[data-key='${key}']`).first(); 35 | } 36 | 37 | triggerDashboardReadyEvent() { 38 | $(document).trigger('dashboard:ready'); 39 | } 40 | 41 | triggerPanelReadyEvent($panel) { 42 | return this.triggerPanelEvent($panel, 'panel'); 43 | } 44 | 45 | triggerPanelEvent($panel, event, additionalData = {}) { 46 | const key = parseInt($panel.data('key'), 10); 47 | const panel = $panel.data('panel'); 48 | const data = { 49 | $element: $panel, 50 | key, 51 | panel, 52 | ...additionalData, 53 | }; 54 | 55 | /* Trigger events and collect info on whether it was cancelled in a handler */ 56 | const generalEvent = $.Event(`dashboard:${event}`); 57 | const namespacedEvent = $.Event(`dashboard:${event}(${panel})`); 58 | $(document).trigger(generalEvent, [data]); 59 | $(document).trigger(namespacedEvent, [data]); 60 | 61 | const prevented = generalEvent.isDefaultPrevented() || namespacedEvent.isDefaultPrevented(); 62 | return !prevented; 63 | } 64 | 65 | setupTabs($tabs) { 66 | const $links = $tabs.find(this.selectors.tabLink); 67 | 68 | const toggleActive = (link, state) => { 69 | const contentId = $(link).attr('href'); 70 | $(link).attr('aria-current', state ? 'true' : 'false') 71 | $(contentId).attr('aria-hidden', state ? 'false' : 'true') 72 | } 73 | 74 | const setActiveLink = (activeLink) => { 75 | $links.each((_, inactiveLink) => toggleActive(inactiveLink, false)); 76 | toggleActive(activeLink, true); 77 | } 78 | 79 | setActiveLink($links.eq(0)); 80 | 81 | $links.on('click', (event) => { 82 | event.preventDefault(); 83 | setActiveLink(event.target); 84 | }); 85 | 86 | $tabs.attr('data-cloak', null); 87 | } 88 | 89 | setupPanel($panel, isReload = false) { 90 | if (isReload) { 91 | setupTooltips($panel); 92 | } 93 | this.triggerPanelReadyEvent($panel); 94 | } 95 | 96 | setupReloadEvents() { 97 | $(document).on('reload', this.selectors.panel, (event, { animate, params } = {}) => { 98 | this.reloadPanel($(event.target), { animate, params }); 99 | }); 100 | } 101 | 102 | setupAutoReload() { 103 | this.$panels.each((_, panel) => { 104 | const $panel = $(panel); 105 | const key = parseInt($panel.data('key'), 10); 106 | let interval = parseInt($panel.data('interval'), 10); 107 | 108 | if (key >= 0 && interval > 0) { 109 | interval = Math.max(2000, interval); 110 | setInterval(() => { 111 | $panel.trigger('reload'); 112 | }, interval); 113 | } 114 | }); 115 | } 116 | 117 | reloadPanel($panel, { animate = false, params = null } = {}) { 118 | if (!$panel.length) return; 119 | 120 | const key = parseInt($panel.data('key'), 10); 121 | const panel = $panel.data('panel'); 122 | const request = { 123 | dashboard: 1, 124 | key, 125 | panel, 126 | ...(params || {}) 127 | }; 128 | 129 | $.post(this.url, request, null, 'text') 130 | .done((data) => { 131 | const $new = $(data); 132 | 133 | // Abort if any event handlers cancelled this reload 134 | const reloadEventAllowed = this.triggerPanelEvent($panel, 'reload', { $new }); 135 | if (!reloadEventAllowed) { 136 | return; 137 | } 138 | 139 | const update = () => { 140 | $panel.html($new.html()); 141 | $panel.prop('className', $new.prop('className')); 142 | $new.filter('script').each((_, script) => { 143 | $.globalEval(script.text || script.textContent || script.innerHTML || ''); 144 | }); 145 | this.setupPanel($panel, true); 146 | }; 147 | if (animate) { 148 | $panel.children().fadeOut(400, () => { 149 | update(); 150 | $panel.children().fadeIn(400); 151 | }); 152 | } 153 | else { 154 | update(); 155 | } 156 | }) 157 | .fail(() => { 158 | console.error('Error fetching panel contents'); 159 | }); 160 | } 161 | } 162 | 163 | $(() => { 164 | const dashboard = new Dashboard(); 165 | dashboard.init(); 166 | }); 167 | -------------------------------------------------------------------------------- /src/DashboardPanelAddNew.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelAddNew {} 2 | 3 | /* Dropdown view */ 4 | 5 | .DashboardPanelAddNew--dropdown { 6 | .DashboardPanelAddNewDropdown { 7 | display: flex; 8 | button:first-of-type { 9 | flex-grow: 1; 10 | } 11 | button:last-of-type { 12 | margin-right: 0 !important; 13 | } 14 | } 15 | } 16 | 17 | /* List view */ 18 | 19 | .DashboardPanelAddNew--list { 20 | .uk-card-body { 21 | padding: 0; 22 | } 23 | ul { 24 | margin: 0; 25 | padding: 0; 26 | list-style: none; 27 | } 28 | li { 29 | margin: 0; 30 | } 31 | a { 32 | display: flex; 33 | align-items: center; 34 | color: inherit; 35 | text-decoration: none; 36 | padding: var(--dashboard-card-padding-list-y) var(--dashboard-card-padding-content-x); 37 | border-bottom: 1px solid var(--dashboard-color-separator); 38 | white-space: nowrap; 39 | &:hover { 40 | background: var(--dashboard-color-list-hover-bg); 41 | } 42 | > span { 43 | display: flex; 44 | align-items: center; 45 | } 46 | } 47 | span + span { 48 | min-width: 0px; 49 | } 50 | .fa { 51 | font-size: var(--dashboard-fontsize-icon) !important; 52 | margin-right: .5em; 53 | color: var(--dashboard-color-icon); 54 | } 55 | a:hover .fa { 56 | color: inherit; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/DashboardPanelChart.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | 3 | :root { 4 | --dashboard-panel-chart-ratio: 2.5; 5 | } 6 | 7 | /* Hide canvas on load. Chart.js will add display: block via JS */ 8 | 9 | .DashboardPanelChart__canvas { 10 | display: none; 11 | } 12 | 13 | /* Display aspect ratio placeholder */ 14 | 15 | .DashboardPanelChart__placeholder { 16 | position: relative; 17 | padding-bottom: calc(100% / var(--dashboard-panel-chart-ratio)); 18 | height: 0; 19 | overflow: hidden; 20 | /* Hide when canvas is visible */ 21 | .DashboardPanelChart__canvas[style] + & { 22 | display: none; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/DashboardPanelChart.js: -------------------------------------------------------------------------------- 1 | /* global Chart, $ */ 2 | 3 | import setChartJSDefaults, { applyDefaultsToChartConfig } from './charts/chartjs-defaults'; 4 | import { registerColorThemePlugin, setDefaultColorTheme } from './charts/color-themes'; 5 | 6 | function initChart($canvas) { 7 | const instance = $canvas.data('chart-instance'); 8 | if (instance) return instance; 9 | 10 | const config = $canvas.data('chart'); 11 | const theme = $canvas.data('theme'); 12 | const defaultTheme = $canvas.data('default-theme'); 13 | setDefaultColorTheme(defaultTheme); 14 | applyDefaultsToChartConfig(config); 15 | config.theme = theme; 16 | 17 | const chart = new Chart($canvas, config); 18 | 19 | $canvas.data('chart-instance', chart); 20 | $canvas.attr('data-setup', true); 21 | 22 | return chart; 23 | } 24 | 25 | function updateChart($canvas, $update) { 26 | const chart = $canvas.data('chart-instance') || initChart($canvas); 27 | const config = $update.data('chart'); 28 | applyDefaultsToChartConfig(config); 29 | 30 | chart.config.data = config.data; 31 | chart.options = config.options; 32 | chart.update(); 33 | } 34 | 35 | function initPanel($panel) { 36 | $panel.find('canvas').each((_, canvas) => { 37 | initChart($(canvas)); 38 | }); 39 | } 40 | 41 | function updatePanel($panel, $new) { 42 | const $canvasses = $panel.find('canvas'); 43 | const $updates = $new.find('canvas'); 44 | $canvasses.each((index, canvas) => { 45 | updateChart($(canvas), $updates.eq(index)); 46 | }); 47 | } 48 | 49 | /* Initialize defaults and plugins */ 50 | setChartJSDefaults(); 51 | registerColorThemePlugin(); 52 | 53 | /* Initialize new panels */ 54 | $(document).on('dashboard:panel(chart)', (event, { $element }) => { 55 | initPanel($element); 56 | }); 57 | 58 | /* Cancel any auto-reloads and update chart manually */ 59 | $(document).on('dashboard:reload(chart)', (event, { $element, $new }) => { 60 | event.preventDefault(); 61 | updatePanel($element, $new); 62 | }); 63 | -------------------------------------------------------------------------------- /src/DashboardPanelCollection.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | 3 | :root { 4 | --dashboard-panel-collection-img-radius: 4px; 5 | --dashboard-panel-collection-img-height: 30px; 6 | --dashboard-panel-collection-color-radius: 50%; 7 | } 8 | 9 | .DashboardPanelCollection { 10 | .uk-card-footer { 11 | font-size: .85em; 12 | color: var(--dashboard-color-text-light); 13 | > div { 14 | display: flex; 15 | justify-content: space-between; 16 | align-items: baseline; 17 | } 18 | } 19 | 20 | /* Bold first or title column */ 21 | td:first-child, 22 | .DashboardTableColumn__title { 23 | font-weight: bold; 24 | } 25 | } 26 | 27 | /* Action column */ 28 | 29 | .DashboardTableColumn__actions__ { 30 | text-align: right !important; 31 | white-space: nowrap; 32 | .tablesorter-header-inner { 33 | display: none; 34 | } 35 | a, 36 | .fa { 37 | display: inline-block; 38 | } 39 | > :not(:first-child) { 40 | margin-left: .25em; 41 | } 42 | .fa { 43 | cursor: not-allowed; 44 | color: var(--dashboard-color-icon); 45 | opacity: .4; 46 | } 47 | a { 48 | /* color: inherit; */ 49 | } 50 | a .fa { 51 | opacity: 1; 52 | cursor: inherit; 53 | /* color: var(--dashboard-color-icon); */ 54 | } 55 | } 56 | 57 | /* Icon column */ 58 | 59 | .DashboardTableColumn__page_icon { 60 | width: 40px; 61 | } 62 | 63 | /* Image column */ 64 | 65 | .DashboardPanelCollection td.is-image-column { 66 | .AdminThemeUikit & { 67 | padding-top: 7px; 68 | padding-bottom: 7px; 69 | vertical-align: middle; 70 | } 71 | img { 72 | border-radius: var(--dashboard-panel-collection-img-radius); 73 | max-height: var(--dashboard-panel-collection-img-height); 74 | width: auto; 75 | } 76 | } 77 | 78 | /* Color column */ 79 | 80 | .DashboardPanelCollection td.is-color-column { 81 | vertical-align: middle; 82 | span { 83 | display: inline-block; 84 | width: 1em; 85 | height: 1em; 86 | border-radius: var(--dashboard-panel-collection-color-radius); 87 | } 88 | } 89 | 90 | /* Switch wrapper */ 91 | 92 | .uk-switch { 93 | --switch-width: 1.9em; 94 | --switch-height: 1.1em; 95 | --switch-pointer-offset: 2px; 96 | --switch-pointer-size: calc(var(--switch-height) - 2 * var(--switch-pointer-offset)); 97 | --switch-active-color: var(--dashboard-color-button-hover-bg, currentColor); 98 | position: relative; 99 | display: inline-block; 100 | width: var(--switch-width); 101 | height: var(--switch-height); 102 | transform: translateY(0.2em); 103 | } 104 | 105 | /* Hide default HTML checkbox */ 106 | 107 | .uk-switch input { 108 | display: none; 109 | } 110 | 111 | /* Switch slider */ 112 | 113 | .uk-switch-slider { 114 | background-color: rgba(0,0,0,0.22); 115 | position: absolute; 116 | top: 0; 117 | left: 0; 118 | right: 0; 119 | border-radius: 999vw; 120 | bottom: 0; 121 | cursor: pointer; 122 | transition-property: background-color; 123 | transition-duration: .2s; 124 | } 125 | 126 | /* Switch pointer */ 127 | .uk-switch-slider:before { 128 | content: ''; 129 | background-color: #fff; 130 | position: absolute; 131 | width: var(--switch-pointer-size); 132 | height: var(--switch-pointer-size); 133 | left: var(--switch-pointer-offset); 134 | bottom: var(--switch-pointer-offset); 135 | border-radius: 50%; 136 | transition-property: transform, box-shadow; 137 | transition-duration: .2s; 138 | } 139 | 140 | /* Active slider */ 141 | input:checked + .uk-switch-slider { 142 | background-color: var(--switch-active-color) !important; 143 | } 144 | 145 | /* Animate to active state */ 146 | input:checked + .uk-switch-slider:before { 147 | transform: translateX(-100%) translateX(calc(var(--switch-width) - var(--switch-pointer-offset) * 2)); 148 | } 149 | -------------------------------------------------------------------------------- /src/DashboardPanelCollection.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | 3 | function initPanel($panel) { 4 | 5 | const submitParamsAndReload = (params) => { 6 | $panel.trigger('reload', { animate: false, params }); 7 | }; 8 | 9 | const $reloadlinks = $panel.find('.pw-modal[data-reload-on-close]'); 10 | if ($reloadlinks.length) { 11 | const page = $panel.data('page') || 1; 12 | const params = { page }; 13 | $reloadlinks.on('pw-modal-closed', () => { 14 | $panel.trigger('reload', { animate: false, params }); 15 | }); 16 | } 17 | 18 | const $paginationLinks = $panel.find('a[data-pagination]'); 19 | if ($paginationLinks.length) { 20 | $paginationLinks.on('click', (event) => { 21 | event.preventDefault(); 22 | const url = event.currentTarget.href; 23 | const page = (url.match(/\d+$/) || [])[0] || 1; 24 | $panel.data('page', page); 25 | submitParamsAndReload({ page }); 26 | }); 27 | } 28 | 29 | const $actionButtons = $panel.find('a[data-action]'); 30 | if ($actionButtons.length) { 31 | $actionButtons.on('click', (event) => { 32 | event.preventDefault(); 33 | const action = event.currentTarget.dataset.action; 34 | const confirm = event.currentTarget.dataset.actionConfirm; 35 | const key = `actions${action}` 36 | const value = event.currentTarget.dataset.actionValue; 37 | const params = { [key]: value }; 38 | console.log(confirm); 39 | if (confirm) { 40 | ProcessWire.confirm(confirm, () => submitParamsAndReload(params)); 41 | } else { 42 | submitParamsAndReload(params); 43 | } 44 | }); 45 | } 46 | 47 | const $actionInputs = $panel.find('input[name^="actions["]'); 48 | if ($actionInputs.length) { 49 | $actionInputs.on('change', (event) => { 50 | event.preventDefault(); 51 | const name = event.target.name; 52 | const checked = event.target.checked; 53 | const value = checked ? 1 : ''; 54 | const params = { [name]: value }; 55 | submitParamsAndReload(params); 56 | }); 57 | } 58 | } 59 | 60 | $(document).on('dashboard:panel(collection)', (event, { $element }) => { 61 | initPanel($element); 62 | }); 63 | -------------------------------------------------------------------------------- /src/DashboardPanelHelloWorld.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelHelloWorld .uk-card-body { 2 | font-style: italic; 3 | } 4 | -------------------------------------------------------------------------------- /src/DashboardPanelHelloWorld.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | 3 | function initPanel($panel) { 4 | const paragraphCount = $panel.find('p').length; 5 | console.log(`Hello world! I found ${paragraphCount} paragraph(s)`); 6 | 7 | /** 8 | * Reload the panel: 9 | * $panel.trigger('reload', { animate: true }); 10 | */ 11 | } 12 | 13 | $(document).on('dashboard:panel(hello-world)', (event, { $element }) => { 14 | initPanel($element); 15 | }); 16 | -------------------------------------------------------------------------------- /src/DashboardPanelNotice.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelNotice { 2 | 3 | .uk-card-header { 4 | display: none; 5 | } 6 | 7 | .uk-card-body { 8 | padding-top: 15px; 9 | padding-bottom: 15px; 10 | display: flex; 11 | 12 | /* Actions */ 13 | 14 | div:last-child { 15 | margin-left: auto; 16 | padding-left: 2em; 17 | a { 18 | color: inherit; 19 | text-decoration: underline; 20 | padding-right: .5em; 21 | white-space: nowrap; 22 | &:last-child { 23 | padding-right: 0; 24 | } 25 | } 26 | } 27 | } 28 | 29 | /* Status types */ 30 | 31 | &.notice-status--notice {} 32 | 33 | &.notice-status--success { 34 | color: rgb(66, 175, 123); 35 | color: hsl(151, 45%, 47%); 36 | background-color: hsl(151, 45%, 95%); 37 | } 38 | 39 | &.notice-status--warning { 40 | color: rgb(235, 116, 25); 41 | color: hsl(26, 84%, 51%); 42 | background-color: hsl(26, 84%, 94%); 43 | } 44 | 45 | &.notice-status--error { 46 | color: rgb(227, 78, 66); 47 | color: hsl(4, 74%, 57%); 48 | background-color: hsl(4, 74%, 96%); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/DashboardPanelNumber.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | 3 | :root { 4 | --dashboard-panel-number-fontsize-number: 2.5em; 5 | --dashboard-panel-number-fontweight-number: bold; 6 | --dashboard-panel-number-color-trend-up: rgb(66, 175, 123); 7 | --dashboard-panel-number-color-trend-down: rgb(227, 78, 66); 8 | } 9 | 10 | .DashboardPanelNumber { 11 | .uk-card-body { 12 | display: flex; 13 | justify-content: stretch; 14 | } 15 | } 16 | 17 | .DashboardPanelNumber__content { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | width: 100%; 22 | text-align: center; 23 | 24 | /* Number */ 25 | 26 | dl { 27 | margin: 10px 0 15px 0; 28 | } 29 | dt, 30 | p { 31 | font-size: var(--dashboard-panel-number-fontsize-number); 32 | font-weight: var(--dashboard-panel-number-fontweight-number); 33 | line-height: 1; 34 | padding: 0 !important; 35 | } 36 | dd { 37 | font-weight: normal; 38 | color: var(--dashboard-color-text-light); 39 | border: none !important; 40 | padding: .25em 0 0 0 !important; 41 | } 42 | 43 | /* Trend indicator */ 44 | 45 | .fa { 46 | display: none; 47 | margin-right: -1.3rem; 48 | font-size: 1.3rem !important; 49 | font-weight: normal; 50 | } 51 | &[data-trend='up'] .fa { 52 | display: inline-block; 53 | transform: rotate(-45deg); 54 | color: var(--dashboard-panel-number-color-trend-up); 55 | } 56 | &[data-trend='down'] .fa { 57 | display: inline-block; 58 | transform: rotate(45deg); 59 | color: var(--dashboard-panel-number-color-trend-down); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/DashboardPanelPageList.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelPageList { 2 | .uk-card-body { 3 | .AdminThemeUikit & { 4 | padding: 0; 5 | } 6 | .AdminThemeReno & { 7 | padding-top: 0; 8 | padding-bottom: 0; 9 | } 10 | } 11 | 12 | .PageList { 13 | margin-top: -5px !important; 14 | margin-bottom: -1px; 15 | .AdminThemeUikit &, 16 | .AdminThemeReno & { 17 | margin-top: 0 !important; 18 | } 19 | } 20 | 21 | .PageListRoot > .PageListLoading { 22 | display: block; 23 | margin: 30px 15px; 24 | } 25 | 26 | .AdminThemeUikit & { 27 | .PageListRootHidden, 28 | .PageListRoot { 29 | > .PageList > .PageListItem { 30 | padding-left: 8px !important; 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/DashboardPanelPageList.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | 3 | const selectors = { 4 | pageList: '.PageListContainerPage, .PageListContainerRoot', 5 | editLink: '.PageListActionEdit a, .PageListActionNew a', 6 | viewLink: '.PageListActionView a', 7 | }; 8 | 9 | function setLinkMode($container, selector, mode) { 10 | $container.on('mouseenter', selector, (event) => { 11 | if (mode === 'blank') { 12 | $(event.target).attr('target', '_blank'); 13 | } 14 | if (mode === 'modal') { 15 | $(event.target).addClass('pw-modal'); 16 | $(event.target).addClass('pw-modal-large'); 17 | $(event.target).removeClass('pw-modal-longclick'); 18 | } 19 | }); 20 | } 21 | function setupEvents($panel) { 22 | const $pagelist = $panel.find(selectors.pageList); 23 | const editMode = $panel.data('edit-mode'); 24 | const viewMode = $panel.data('view-mode'); 25 | 26 | if ($pagelist.data('has-events')) { 27 | return; 28 | } 29 | 30 | // Set link modes for editing and viewing 31 | setLinkMode($pagelist, selectors.editLink, editMode); 32 | setLinkMode($pagelist, selectors.viewLink, viewMode); 33 | 34 | // Reload page list when panel is closed 35 | $pagelist.on('pw-modal-closed', selectors.editLink, () => { 36 | $panel.trigger('reload', { animate: true }); 37 | }); 38 | 39 | $pagelist.data('has-events', true); 40 | } 41 | 42 | function initPanel($panel) { 43 | // Get options from panel element 44 | const $pagelist = $panel.find(selectors.pageList); 45 | const parent = parseInt($panel.data('parent'), 10); 46 | const showRoot = $panel.data('show-root'); 47 | 48 | // Defer to ProcessWire PageList component 49 | $pagelist.ProcessPageList({ 50 | rootPageID: parent, 51 | showRootPage: showRoot, 52 | }); 53 | 54 | // Register events after (hopefully) pagelist is loaded 55 | setTimeout(() => { 56 | setupEvents($panel); 57 | }, 1000); 58 | } 59 | 60 | $(document).on('dashboard:panel(page-list)', (event, { $element }) => { 61 | initPanel($element); 62 | }); 63 | -------------------------------------------------------------------------------- /src/DashboardPanelShortcuts.css: -------------------------------------------------------------------------------- 1 | .DashboardPanelShortcuts { 2 | ul { 3 | margin: 0; 4 | padding: 0; 5 | list-style: none; 6 | } 7 | li { 8 | margin: 0; 9 | } 10 | a { 11 | display: flex; 12 | align-items: center; 13 | color: inherit; 14 | text-decoration: none; 15 | > span { 16 | display: flex; 17 | align-items: center; 18 | } 19 | } 20 | span + span { 21 | min-width: 0px; 22 | } 23 | .fa { 24 | margin-right: .5em; 25 | font-size: var(--dashboard-fontsize-icon) !important; 26 | color: var(--dashboard-color-icon); 27 | } 28 | a:hover .fa { 29 | color: inherit; 30 | } 31 | .summary { 32 | white-space: nowrap; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | color: var(--dashboard-color-text-light); 36 | padding-left: 1em; 37 | display: none; 38 | } 39 | } 40 | 41 | /* Grid view */ 42 | 43 | .DashboardPanelShortcuts--grid { 44 | ul { 45 | display: grid; 46 | grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); 47 | grid-gap: 1.1em .7em; 48 | margin-bottom: -1px; /* hide last row's border */ 49 | } 50 | } 51 | 52 | /* List view */ 53 | 54 | .DashboardPanelShortcuts--list { 55 | .uk-card-body { 56 | padding: 0; 57 | } 58 | a { 59 | padding: var(--dashboard-card-padding-list-y) var(--dashboard-card-padding-content-x); 60 | border-bottom: 1px solid var(--dashboard-color-separator); 61 | white-space: nowrap; 62 | overflow: hidden; 63 | text-overflow: ellipsis; 64 | &:hover { 65 | background: var(--dashboard-color-list-hover-bg); 66 | } 67 | } 68 | .title { 69 | font-weight: bold; 70 | } 71 | .summary { 72 | display: block; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/charts/chartjs-defaults.js: -------------------------------------------------------------------------------- 1 | /* global Chart */ 2 | 3 | import get from 'lodash/get' 4 | import set from 'lodash/set' 5 | 6 | export default function setGlobalChartJSDefaults() { 7 | // Layout 8 | Chart.defaults.global.animation.duration = 0; 9 | Chart.defaults.global.aspectRatio = 2.5; 10 | Chart.defaults.global.layout.padding = 5; 11 | 12 | // Scales 13 | Chart.defaults.scale.gridLines.drawBorder = false; 14 | Chart.defaults.scale.color = 'rgba(0, 0, 0, 0.07)'; 15 | Chart.defaults.scale.zeroLineColor = 'rgba(0, 0, 0, 0.07)'; 16 | Chart.defaults.scale.drawBorder = false; 17 | Chart.defaults.scale.ticks.beginAtZero = true; 18 | 19 | // Legends 20 | Chart.defaults.global.legend.position = 'bottom'; 21 | Chart.defaults.global.legend.labels.fontColor = 'rgb(110, 110, 110)'; 22 | Chart.defaults.global.legend.labels.usePointStyle = true; 23 | Chart.defaults.global.legend.labels.boxWidth = 4; 24 | Chart.defaults.global.legend.labels.boxWidthByChartType = { doughnut: 4, pie: 8 }; 25 | 26 | // Tooltips 27 | Chart.defaults.global.tooltips.titleFontColor = 'rgb(53, 75, 96)'; 28 | Chart.defaults.global.tooltips.backgroundColor = 'rgb(240, 243, 247)'; // white 29 | Chart.defaults.global.tooltips.bodyFontColor = 'rgba(53, 75, 96, 0.6)'; 30 | Chart.defaults.global.tooltips.displayColors = false; 31 | Chart.defaults.global.tooltips.titleFontSize = 14; 32 | Chart.defaults.global.tooltips.bodyFontSize = 14; 33 | Chart.defaults.global.tooltips.cornerRadius = 4; 34 | Chart.defaults.global.tooltips.xPadding = 10; 35 | Chart.defaults.global.tooltips.yPadding = 10; 36 | 37 | // Lines 38 | Chart.defaults.global.elements.line.backgroundColor = 'transparent'; 39 | Chart.defaults.global.elements.line.clip = 20; 40 | Chart.defaults.global.elements.line.borderWidth = 2; 41 | 42 | // Doughnuts & Arcs 43 | Chart.defaults.doughnut.cutoutPercentage = 75; 44 | Chart.defaults.global.elements.arc.borderWidth = 4; 45 | Chart.defaults.global.elements.arc.borderColor = 'white'; 46 | Chart.defaults.global.elements.arc.hoverBorderColor = 'white'; 47 | 48 | // Points 49 | Chart.defaults.global.elements.point.backgroundColor = 'white'; 50 | Chart.defaults.global.elements.point.radius = 3; 51 | Chart.defaults.global.elements.point.hoverRadius = 4; 52 | Chart.defaults.global.elements.point.borderWidth = 2; 53 | Chart.defaults.global.elements.point.hoverBorderWidth = 2; 54 | } 55 | 56 | export function applyDefaultsToChartConfig (config) { 57 | const type = config.type; 58 | const labelWidth = get(config, 'options.legend.labels.boxWidth') 59 | if (!labelWidth) { 60 | const defaultLabelWidth = Chart.defaults.global.legend.labels.boxWidth 61 | const chartLabelWidth = Chart.defaults.global.legend.labels.boxWidthByChartType[type] || defaultLabelWidth 62 | set(config, 'options.legend.labels.boxWidth', chartLabelWidth) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/charts/color-themes.js: -------------------------------------------------------------------------------- 1 | /* global Chart */ 2 | 3 | const themes = { 4 | dashboard: [ 5 | 'rgb(23, 185, 120)', // green 6 | 'rgb(103, 114, 229)', // purple 7 | 'rgb(219, 120, 221)', // pink 8 | 'rgb(244, 190, 86)', // orange 9 | 'rgb(35, 164, 240)', // blue 10 | ], 11 | airtable: [ 12 | 'rgb(8, 157, 88)', // green 13 | 'rgb(168, 71, 189)', // purple 14 | 'rgb(62, 134, 246)', // blue 15 | 'rgb(217, 70, 55)', // red 16 | 'rgb(240, 181, 0)', // yellow 17 | ], 18 | processwire: [ 19 | 'rgb(37, 128, 230)', // blue 20 | 'rgb(233, 53, 97)', // red 21 | 'rgb(69, 183, 151)', // green 22 | 'rgb(28, 40, 53)', // dark blue 23 | ], 24 | reminders: [ 25 | 'rgb(92, 91, 231)', // blue 26 | 'rgb(253, 71, 59)', // red 27 | 'rgb(252, 160, 11)', // yellow 28 | 'rgb(41, 209, 92)', // green 29 | 'rgb(211, 128, 246)', // purple 30 | ], 31 | workflow: [ 32 | 'rgb(99, 142, 196)', // blue 33 | 'rgb(68, 158, 135)', // green 34 | 'rgb(186, 101, 192)', // purple 35 | 'rgb(222, 83, 87)', // red 36 | 'rgb(209, 151, 40)', // yellow 37 | ], 38 | }; 39 | 40 | let defaultTheme = themes.processwire; 41 | 42 | const setDefaultColorTheme = (name) => { 43 | defaultTheme = themes[name] || defaultTheme; 44 | }; 45 | 46 | const color = (theme, index) => theme[index % theme.length]; 47 | 48 | const colorThemePlugin = { 49 | beforeUpdate(chart) { 50 | /* eslint-disable no-param-reassign */ 51 | 52 | const theme = themes[chart.config.theme] || defaultTheme; 53 | 54 | switch (chart.config.type) { 55 | 56 | case 'bar': 57 | chart.data.datasets.forEach((dataset, index) => { 58 | if (!dataset.borderColor) { 59 | dataset.borderColor = color(theme, index); 60 | if (!dataset.backgroundColor) { 61 | dataset.backgroundColor = dataset.borderColor; 62 | } 63 | } 64 | }); 65 | break; 66 | 67 | case 'line': 68 | chart.data.datasets.forEach((dataset, index) => { 69 | if (!dataset.borderColor) { 70 | dataset.borderColor = color(theme, index); 71 | if (!dataset.backgroundColor) { 72 | dataset.pointHoverBackgroundColor = dataset.borderColor; 73 | } 74 | if (!dataset.pointHoverBackgroundColor) { 75 | dataset.pointHoverBackgroundColor = dataset.borderColor; 76 | } 77 | } 78 | }); 79 | break; 80 | 81 | case 'pies': 82 | chart.data.datasets.forEach((dataset) => { 83 | if (!dataset.backgroundColor) { 84 | const colorArray = dataset.data.map((_, index) => color(theme, index)); 85 | dataset.backgroundColor = colorArray; 86 | } 87 | if (!dataset.borderColor) { 88 | dataset.borderColor = 'white'; 89 | } 90 | }); 91 | break; 92 | 93 | case 'doughnut': 94 | chart.data.datasets.forEach((dataset) => { 95 | if (!dataset.backgroundColor) { 96 | const colorArray = dataset.data.map((_, index) => color(theme, index)); 97 | dataset.backgroundColor = colorArray; 98 | } 99 | if (!dataset.borderColor) { 100 | dataset.borderColor = 'white'; 101 | } 102 | }); 103 | break; 104 | 105 | default: 106 | // 107 | 108 | } 109 | }, 110 | }; 111 | 112 | const registerColorThemePlugin = () => { 113 | Chart.pluginService.register(colorThemePlugin); 114 | }; 115 | 116 | export default themes; 117 | 118 | export { 119 | themes, 120 | registerColorThemePlugin, 121 | setDefaultColorTheme, 122 | }; 123 | -------------------------------------------------------------------------------- /src/lib/tooltips.js: -------------------------------------------------------------------------------- 1 | /* global $, UIkit */ 2 | 3 | function setupDefaultTooltips(context) { 4 | /* eslint-disable func-names */ 5 | $('a.tooltip, .pw-tooltip', context).tooltip({ 6 | position: { 7 | my: 'center bottom', // bottom-20 8 | at: 'center top', 9 | }, 10 | }).hover(function () { 11 | const $a = $(this); 12 | if ($a.is('a')) { 13 | $a.addClass('ui-state-hover'); 14 | } 15 | else { 16 | $a.data('pw-tooltip-cursor', $a.css('cursor')); 17 | $a.css('cursor', 'pointer'); 18 | } 19 | $a.addClass('pw-tooltip-hover'); 20 | $a.css('cursor', 'pointer'); 21 | }, function () { 22 | const $a = $(this); 23 | $a.removeClass('pw-tooltip-hover ui-state-hover'); 24 | if (!$a.is('a')) { 25 | $a.css('cursor', $a.data('pw-tooltip-cursor')); 26 | } 27 | }); 28 | } 29 | 30 | function setupUiKitTooltips(context) { 31 | /* eslint-disable func-names */ 32 | $('.tooltip, .pw-tooltip', context).each(function () { 33 | $(this).removeClass('tooltip pw-tooltip'); 34 | UIkit.tooltip($(this)); 35 | }); 36 | } 37 | 38 | export default function setupTooltips(context = document) { 39 | if (typeof UIkit !== 'undefined') { 40 | setupUiKitTooltips(context); 41 | } else { 42 | setupDefaultTooltips(context); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /views/dashboard.php: -------------------------------------------------------------------------------- 1 |
    5 | 6 |
    7 | 8 |
    9 |
    10 |

    11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

    19 |
    20 | 21 |
    22 |

    23 |

    empty_panel_notice ?>

    24 |

    setup_hint, $texts->docs_url) ?>

    25 |

    26 | 27 | get_started ?> 28 | 29 |

    30 |
    31 | 32 |
    33 | -------------------------------------------------------------------------------- /views/group.php: -------------------------------------------------------------------------------- 1 |
    7 | 8 | 9 |

    10 | 11 |

    12 | 13 | 14 | 15 | 16 | 17 | 18 |
    19 | -------------------------------------------------------------------------------- /views/panel.php: -------------------------------------------------------------------------------- 1 |
    10 | > 11 | 12 | 13 |
    14 |

    15 | 16 |

    17 |
    18 | 19 | 20 |
    21 | 22 |
    23 | 24 | 25 | 28 | 29 | 30 |
    31 | -------------------------------------------------------------------------------- /views/panels.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
    4 | 9 | 10 |
    11 | panels ?> 12 |
    13 | 14 |
    15 | 16 | 17 | 18 | 19 | 20 |
    21 | 22 | 23 | 24 |
    25 | 26 | 27 | -------------------------------------------------------------------------------- /views/panels/chart.php: -------------------------------------------------------------------------------- 1 |
    2 | 8 |
    9 |
    10 | -------------------------------------------------------------------------------- /views/panels/number.php: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 |
    5 |
    6 |
    7 |
    8 | 9 |

    10 | 11 | 12 |
    13 | --------------------------------------------------------------------------------