This commit is contained in:
Loïc Guibert
2022-09-30 20:02:02 +01:00
commit 66dafc36c3
2561 changed files with 454489 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
{
"root": true,
"env": {
"browser": true,
"node": true,
"es6": true
},
"parser": "@babel/eslint-parser",
"parserOptions": {
"ecmaVersion": 7,
"sourceType": "module",
"requireConfigFile": false
},
"rules": {
"accessor-pairs": 2,
"array-bracket-spacing": 0,
"block-scoped-var": 0,
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"camelcase": 0,
"comma-dangle": [2, "never"],
"comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"],
"complexity": 0,
"computed-property-spacing": 0,
"consistent-return": 0,
"consistent-this": 0,
"constructor-super": 2,
"curly": [2, "multi-line"],
"default-case": 0,
"dot-location": [2, "property"],
"dot-notation": 0,
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"func-names": 0,
"func-style": 0,
"generator-star-spacing": [2, { "before": true, "after": true }],
"guard-for-in": 0,
"handle-callback-err": [2, "^(err|error)$" ],
"indent": [2, 4, { "SwitchCase": 1 }],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"linebreak-style": 0,
"lines-around-comment": 0,
"max-nested-callbacks": 0,
"new-cap": [2, { "newIsCap": true, "capIsNew": false }],
"new-parens": 2,
"newline-after-var": 0,
"no-alert": 0,
"no-array-constructor": 2,
"no-caller": 2,
"no-catch-shadow": 0,
"no-cond-assign": 2,
"no-console": 0,
"no-constant-condition": 0,
"no-continue": 0,
"no-control-regex": 2,
"no-debugger": 2,
"no-delete-var": 2,
"no-div-regex": 0,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-else-return": 0,
"no-empty": 0,
"no-empty-character-class": 2,
"no-eq-null": 0,
"no-eval": 2,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": 0,
"no-extra-semi": 0,
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inline-comments": 0,
"no-inner-declarations": [2, "functions"],
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": 2,
"no-lone-blocks": 2,
"no-lonely-if": 0,
"no-loop-func": 0,
"no-mixed-requires": 0,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 1 }],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-nested-ternary": 0,
"no-new": 2,
"no-new-func": 0,
"no-new-object": 2,
"no-new-require": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-param-reassign": 0,
"no-path-concat": 0,
"no-process-env": 0,
"no-process-exit": 0,
"no-proto": 0,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-restricted-modules": 0,
"no-return-assign": 2,
"no-script-url": 0,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 0,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-sync": 0,
"no-ternary": 0,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-undefined": 0,
"no-underscore-dangle": 0,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": 2,
"no-unreachable": 2,
"no-unused-expressions": 0,
"no-unused-vars": [2, { "vars": "all", "args": "none" }],
"no-use-before-define": 0,
"no-var": 0,
"no-void": 0,
"no-warning-comments": 0,
"no-with": 2,
"object-curly-spacing": 0,
"object-shorthand": 0,
"one-var": [2, { "initialized": "never" }],
"operator-assignment": 0,
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
"padded-blocks": 0,
"prefer-const": 0,
"quote-props": 0,
"quotes": [2, "single", "avoid-escape"],
"radix": 2,
"semi": [2, "always"],
"semi-spacing": 0,
"sort-vars": 0,
"keyword-spacing": [2, {"after": true, "overrides": {"throw": { "after": true}, "return": { "before": true }}}],
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, "never"],
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": [2, "always", { "markers": ["global", "globals", "eslint", "eslint-disable", "*package", "!"] }],
"strict": 0,
"use-isnan": 2,
"valid-jsdoc": 0,
"valid-typeof": 2,
"vars-on-top": 0,
"wrap-iife": [2, "any"],
"wrap-regex": 0,
"yoda": [2, "never"]
}
}

3
user/plugins/flex-objects/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.idea
.DS_Store
node_modules

View File

@@ -0,0 +1,523 @@
# v1.3.1
## 09/08/2022
1. [](#bugfix)
* Fixed `covnertUrls` action
# v1.3.0
## 06/14/2022
1. [](#new)
* Added user object to `onFlexTask.*` and `onFlexAction.*` events
* Added tasks `MediaUploadMeta` and `MediaReorder` to support remote media fields
* Added support to remove media defined in a field
2. [](#improved)
* Refactored admin controller tasks and actions
* Added image preview support for 3rd party editors
1. [](#bugfix)
* Fixed broken error responses in object media tasks
# v1.2.0
## 03/28/2022
1. [](#new)
* Require **Grav 1.7.32** and **Form 6.0.0**
2. [](#improved)
* Improved flex router event to include directory
3. [](#bugfix)
* Fixed caching issues in dynamic flex forms
* Fixed flex content in unauthorized module causing the whole page to become unauthorized
# v1.1.9
## 03/14/2022
1. [](#new)
* Added support for flex router to return a response instead of a page
# v1.1.8
## 01/28/2022
1. [](#new)
* Require **Grav 1.7.29**
3. [](#improved)
* Made path handling unicode-safe, use new `Utils::basename()` and `Utils::pathinfo()` everywhere
# v1.1.7
## 01/03/2022
1. [](#new)
* Allow intercepting object `create`, `update` and `delete` tasks by using `FlexTaskEvent` event
2. [](#improved)
* Added optional `$scope` parameter to `ObjectController::checkAuthorization()`
3. [](#bugfix)
* Fixed continue task with `PageInterface` types
# v1.1.6
## 11/29/2021
1. [](#bugfix)
* Fixed regression `Call to a member function getRoute() on null` when using CLI [#151](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/151)
# v1.1.5
## 11/24/2021
1. [](#new)
* Added method `ObjectController::checkAuthorizations()` to check if one of the actions is true
2. [](#bugfix)
* Fixed regression when calling flex router with a path
# v1.1.4
## 11/16/2021
1. [](#new)
* Require **Grav 1.7.25**
1. [](#improved)
* Changed flex router not to trigger `onPageNotFound` event
* Changed flex router to be called also with empty path
* If ACL check for the object fails, display unauthorized page instead of 404
1. [](#bugfix)
* Fixed unescaped messages in JSON responses
* Fixed `Call to a member function getName() on null` when using file field [#149](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/149)
# v1.1.3
## 10/26/2021
1. [](#improved)
* Updated JS dependencies to latest
* Optimized import of certain JS dependencies
* Dev: Moved away from deprecated UglifyJsPlugin in favor of TerserPlugin
* Use active form from the Form plugin to get page metadata
* Added page header `flex.access.override: true`, which allows flex to replace page `access` when user is allowed to perform action in flex
1. [](#bugfix)
* Fixed flex object page access for super users when permission was denied
# v1.1.2
## 09/14/2021
1. [](#new)
* Require **Grav 1.7.21**, optionally **Error 1.8.0**, **Login 3.5.2** and **Form 5.1.1**
* Added file upload/delete support to frontend forms
* Support proper error, login and unauthorized pages if all requirements are met
* Added page header `flex.router: [ROUTER]` which triggers `flex.router.[ROUTER]` event for child routes of the page
* Added `flex.[type].task.create.after`, `flex.[type].task.update.after` and `flex.[type].task.delete.after` events for frontend
# v1.1.1
## 09/01/2021
1. [](#bugfix)
* Fixed XSS in page admin
* Fixed check for bad folder name, prevent bad characters
# v1.1.0
## 08/31/2021
1. [](#new)
* Require **Grav 1.7.19** and **Form 5.1.0**
* Added basic frontend editing support
* Added `onBeforeFlexFormInitialize` event to help to initialize the frontend form
1. [](#bugfix)
* Fixed error in admin when field validation fails
# v1.0.16
## 07/19/2021
1. [](#new)
* Added basic new modal support for all flex types
1. [](#bugfix)
* Fixed authorization check for user configuration
# v1.0.15
## 06/16/2021
1. [](#improved)
* Better checks against missing Flex Type inside tasks
* Better authorization checks, falls back to directory level authorization checks if objects do not support authorization
1. [](#bugfix)
* Fixed missing handling of child_type in Flex Pages [getgrav/grav-plugin-admin#2087](https://github.com/getgrav/grav-plugin-admin/issues/2087)
* Added support for multiple `Exports` in a dropdown
* Admin is no longer a dependency of Flex Objects [#130](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/130)
* Fixed authorization checks during page creation for users who have limited access to some pages [getgrav/grav#3382](https://github.com/getgrav/grav/issues/3382)
* Fixed permission check when moving a page [getgrav/grav#3382](https://github.com/getgrav/grav/issues/3382)
# v1.0.14
## 06/07/2021
1. [](#improved)
* Added enhanced copy modal from Pages list [getgrav/grav-plugin-admin#2139](https://github.com/getgrav/grav-plugin-admin/issues/2139)
# v1.0.13
## 06/03/2021
1. [](#bugfix)
* Fixed expert mode for Flex Pages
# v1.0.12
## 06/02/2021
1. [](#bugfix)
* Fixed logic to get form blueprints and object, prevents events from being fired twice
* Fixed breadcrumb item in Pages list not translating HTML entities [#127](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/127)
# v1.0.11
## 05/24/2021
1. [](#improved)
* Allow file uploads to send data such as `data[media_order]`
# v1.0.10
## 05/19/2021
1. [](#bugfix)
* Fixed `Add Folder` not updating the page list until cache is cleared
* Fixed broken error message translations
# v1.0.9
## 04/29/2021
1. [](#bugfix)
* Fixed fatal error when copying a page in admin if no modal is being shown [getgrav/grav#3335](https://github.com/getgrav/grav/issues/3335)
# v1.0.8
## 04/23/2021
1. [](#new)
* Require **Admin 1.10.13**
* Require **Form Plugin 5.0.2**
1. [](#improved)
* Added a few missing translations
* Utilize new Admin detector to prevent Save actions that triggers unsaved notice on unload [getgrav/grav-plugin-admin#2125](https://github.com/getgrav/grav-plugin-admin/issues/2125)
* Improved copying page by adding a modal for new page title and folder name
# v1.0.7
## 04/06/2021
1. [](#new)
* Require **Grav 1.7.10**
* Added deny option support to `filepicker` field [#119](https://github.com/trilbymedia/grav-plugin-flex-objects/pull/119)
1. [](#bugfix)
* Prevent expert editing mode from anyone else than super users [grav-plugin-admin#2094](https://github.com/getgrav/grav-plugin-admin/issues/2094)
* Fixed not being able to add new folder [grav#3293](https://github.com/getgrav/grav/issues/3293)
* Fixed Flex directories defined only in theme not showing up [grav#3292](https://github.com/getgrav/grav/issues/3292)
# v1.0.6
## 03/30/2021
1. [](#bugfix)
* Fixed automatic git-sync in admin save and delete [#120](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/120)
* Prevent Add Page / Add Module modals from closing if clicking on the outside overlay [grav-plugin-admin#2089](https://github.com/getgrav/grav-plugin-admin/issues/2089)
# v1.0.5
## 03/19/2021
1. [](#new)
* Require **Grav 1.7.9**
* Require **Form Plugin 5.0.1**
1. [](#improved)
* Catch JSON decoding issues in controllers
1. [](#bugfix)
* Fixed broken media upload/picker fields with `@self/path` notations [grav#3275](https://github.com/getgrav/grav/issues/3275)
* Fixed `filepicker` field not including newly uploaded and excluding newly deleted files before saving the object
* Fixed `Flex Page` CRUD ACL when creating a new page [#115](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/115)
* Bumped dependencies versions [#116](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/116)
* Fixed clicking `move` button on some pages resulting in endless loading spinner [grav-plugin-admin#2095](https://github.com/getgrav/grav-plugin-admin/issues/2095)
# v1.0.4
## 03/17/2021
1. [](#improved)
* Added id attributes for buttons to help on acceptance testing
1. [](#bugfix)
* Fixed fatal error in `/admin/flex-objects` [#114](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/114)
* Fixed `onAdminSave` original page having empty header [grav#3259](https://github.com/getgrav/grav/issues/3259)
* Fixed flash issues on uploading files into a new page
# v1.0.3
## 02/17/2021
1. [](#improved)
* List field: added new `placement` property to decide whether to add new items at the top, bottom or based on the *position* of the clicked button [#105](https://github.com/trilbymedia/grav-plugin-flex-objects/pull/105)
* Added default styling for Flex-Objects Admin list view
1. [](#bugfix)
* Fixed fatal error if configuration is missing directories [#107](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/107)
* Fixed case-sensitive `accept` in `filepicker` field
* Fixed pages admin being accessible without read/write permissions [grav-plugin-admin#2053](https://github.com/getgrav/grav-plugin-admin/issues/2053)
* Fixed missing event `onAdminCreatePageFrontmatter` when creating a new page [grav-plugin-auto-date#8](https://github.com/getgrav/grav-plugin-auto-date/issues/8)
* Fixed missing event `onAdminAfterDelMedia` when deleting a file from a page
* Fixed filepicker support for old `theme@:/` and `page@:/` notations [#109](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/109)
* Fixed adding the same new page twice remembering content from the last try
* Fixed saving a new page with invalid data makes blueprint fields disappear [grav-plugin-admin#2068](https://github.com/getgrav/grav-plugin-admin/issues/2068)
# v1.0.2
## 02/01/2021
1. [](#new)
* Require **Grav 1.7.4**
1. [](#bugfix)
* Fixed saving page in expert mode [grav#3174](https://github.com/getgrav/grav/issues/3174)
# v1.0.1
## 01/20/2021
1. [](#bugfix)
* Fixed 404 when trying to edit a page with accented characters [grav-plugin-admin#2026](https://github.com/getgrav/grav-plugin-admin/issues/2026)
# v1.0.0
## 01/19/2021
1. [](#new)
* Added `$grav['flex_objects']->getAdminController()` method
1. [](#improved)
* Added support for relative paths in `getLevelListing` action
1. [](#bugfix)
* Fixed admin not working with types that do not implement `FlexAuthorizeInterface`
* Fixed bad redirect when creating new flex object and choosing to create another return to the list
* Fixed bad redirect when changing parent of new page and saving [grav-plugin-admin#2014](https://github.com/getgrav/grav-plugin-admin/issues/2014)
* Fixed page forms being empty if multi-language is enabled, but there's just one language [grav#3147](https://github.com/getgrav/grav/issues/3147)
* Fixed copying a page within a parent with no create permission [grav-plugin-admin#2002](https://github.com/getgrav/grav-plugin-admin/issues/2002)
# v1.0.0-rc.20
## 12/15/2020
1. [](#improved)
* Default cookies usage to SameSite Lax [grav-plugin-admin#1998](https://github.com/getgrav/grav-plugin-admin/issues/1998)
* Fixed typo [#89](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/89)
# v1.0.0-rc.19
## 12/02/2020
1. [](#improved)
* Just keeping sync with Grav rc.19
# v1.0.0-rc.18
## 12/02/2020
1. [](#new)
* Require **PHP 7.3.6**
1. [](#improved)
* Improved frontend templates
* Improve blueprint structure
* Hooked up Duplicate and Move from within Pages list [#81](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/81)
* Respect CRUD ACL actions for items shortcuts in pages list [#82](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/82)
* Refresh object on controllers to make sure it is up to date
1. [](#bugfix)
* Fixed fatal error in admin if list view hasn't been defined
* Fixed fatal error in admin if directory throws exception
* Fixed attempts to add an existing page
* Fixed form loosing its form state if saving fails when using `ObjectController`
* Fixed missing context when rendering collection in frontend
* Fixed Flex Admin activating on too old Admin plugin versions
# v1.0.0-rc.17
## 10/07/2020
1. [](#bugfix)
* Fixed media uploads for objects which do not implement `FlexAuthorizeInterface`
* Fixed file picker field not recognizing `folder: @self` variants
# v1.0.0-rc.16
## 09/01/2020
1. [](#improved)
* Simplified `Flex Pages` admin not to differentiate between default language file extensions [#47](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/47)
1. [](#bugfix)
* Fixed extra space in Flex admin pages
* Fixed folder creation with parent other than root [#66](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/66)
* Fixed task redirects in sub-folder multi-site environments
* Fixed typo in default permissions (should have been `admin.flex-objects`) [grav#2915](https://github.com/getgrav/grav/issues/2915)
# v1.0.0-rc.15
## 07/22/2020
1. [](#new)
* Released with no changes to keep sync with Grav + Admin
# v1.0.0-rc.14
## 07/09/2020
1. [](#new)
* Released with no changes to keep sync with Grav + Admin
# v1.0.0-rc.13
## 07/01/2020
1. [](#bugfix)
* Fixed bad link in directory listing template
* Fixed admin save task displaying error message about non-existing data type
* Fixed `pagemedia` field not uploading/deleting files right away
* Fixed `Flex Pages` add, copy and move buttons appearing in edit view when no permissions
* Fixed `Flex Pages` permission issues
* Fixed some admin redirect issues
# v1.0.0-rc.12
## 06/08/2020
1. [](#new)
* Code updates to match Grav 1.7.0-rc.12
1. [](#improved)
* Changed class `admin-pages` to `admin-{{ target }}` [#59](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/59)
# v1.0.0-rc.11
## 05/14/2020
1. [](#new)
* Added integration with Admin's new preset events to style the CSS
1. [](#improved)
* JS Maitenance
1. [](#bugfix)
* Fixed `Accounts` Configuration tab
# v1.0.0-rc.10
## 04/27/2020
1. [](#bugfix)
* Fixed custom actions not working
* Fixed custom folder in `mediapicker` field not working
* Fixed export title when not using CVS [#51](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/51)
* Fixed preview in Page list view [admin#1845](https://github.com/getgrav/grav-plugin-admin/issues/1845)
* Fixed `404 Not Found` error after saving a new object
# v1.0.0-rc.9
## 03/20/2020
1. [](#bugfix)
* Fixed issue with touch devices and scrollbars hidden, preventing native scrolling to work [admin#1857](https://github.com/getgrav/grav-plugin-admin/issues/1857) [#1858](https://github.com/getgrav/grav-plugin-admin/issues/1858)
# v1.0.0-rc.8
## 03/19/2020
1. [](#new)
* Added a basic **Convert Data** CLI Command. Works with `Yaml` <-> `Json`
1. [](#bugfix)
* Fixed jump of the page when applying filters [grav-admin#1830](https://github.com/getgrav/grav-plugin-admin/issues/1830)
* Fixed form resetting when validation fails [grav#2764](https://github.com/getgrav/grav/issues/2764)
# v1.0.0-rc.7
## 03/05/2020
1. [](#new)
* Added option to change perPage amount of items in Flex List. 'All' also available by only at runtime.
1. [](#improved)
* Page filters now obey admin hide type settings
1. [](#bugfix)
* Fixed fatal error if there is missing blueprint [grav#2834](https://github.com/getgrav/grav/issues/2834)
* Fixed redirect when moving a page [grav#2829](https://github.com/getgrav/grav/issues/2829)
* Fixed no default access set when creating new user from admin [#31](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/31)
* Flex Pages: Fixed page visibility issues when creating a new page [grav#2823](https://github.com/getgrav/grav/issues/2823)
* Flex Pages: Fixed translated page having non-translated status with `system.languages.include_default_lang_file_extension: false`
* Flex Pages: Fixed preview on home page
# v1.0.0-rc.6
## 02/11/2020
1. [](#new)
* Pass phpstan level 1 tests
* Removed legacy classes for pages, cleanup deprecated Flex types
1. [](#bugfix)
* Fixed call to `grav.flex_objects.getObject()` causing fatal error
* Minor bug fixes
# v1.0.0-rc.5
## 02/03/2020
1. [](#new)
* No changes, just keeping things in sync with Grav RC version
# v1.0.0-rc.4
## 02/03/2020
1. [](#new)
* Added support for arbitrary admin menu route for editing a flex type
* Added support for new improved ACL
* Added support for custom layouts by adding `/:layout_name` in url
* Added support for Flex Directory specific Configuration
* Added support for action aliases (`/accounts/configure` instead of `/accounts/users/:configre`)
* Added Flex type `Configuration`
* Enabled `Pages`, `Accounts` and `User Groups` by default
* Stop using deprecated `onAdminRegisterPermissions` event
* Renamed directory `grav-pages` to `pages`
* Renamed directory `grav-accounts` to `user-accounts`
* Renamed directory `grav-user-groups` to `user-groups`
1. [](#improved)
* Flex caching settings were moved into Grav core
* Flex Objects plugin now better integrates to Grav core
1. [](#bugfix)
* Fixed empty directory entries in plugin configuration
* Fixed plugin configuration displaying directories outside of the plugin
* Fixed broken blueprints if there's folder with the name of the blueprint file
* Fixed visible save button when in 404 page
* Fixed missing save location when file does not exist
* Fixed multiple ACL related issues (no access, bad links, information leaks)
* Fixed Admin Panel Page list buttons not appearing in Flex Pages
# v1.0.0-rc.3
## 01/02/2020
1. [](#new)
* Added root page support for `Flex Pages`
1. [](#bugfix)
* Fixed after save: Edit
* Fixed JS failing on initial filters setup due to no fallback implemented [#2724](https://github.com/getgrav/grav/issues/2724)
# v1.0.0-rc.2
## 12/04/2019
1. [](#new)
* Admin: Added support for editing `User Groups`
* Admin: `Flex Pages` now support **searching** and **filtering**
1. [](#bugfix)
* Hide hidden/system types (pages, accounts, user groups) from Flex Objects page type [#38](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/38)
# v1.0.0-rc.1
## 11/06/2019
1. [](#new)
* Added directory configuration option for custom admin templates
* Added `Flex Accounts (Admin)` type to administer user accounts in Flex independently from Grav system setting
* Added `Flex Pages (Admin)` type to administer pages in Flex independently from Grav system setting
* Added blueprint option to hide directory from Flex Objects types page in frontend
* Deprecated all `Flex Page` classes and traits in favor of the new classes in Grav core
* Moved flex object/collection templates to `templates/flex/{TYPE}` which is easier to remember
* Admin: Added support customizable preview and export
1. [](#improved)
* Admin: Allow custom title template when editing object
* Translations: rename MODULAR to MODULE everywhere
1. [](#bugfix)
* Flex Pages: Fixed default language not being translated in both `translatedLanguages()` and `untranslatedLanguages()` results
* Flex Pages: Language interface compatibility fixes
* Flex Pages: Fixed frontend issues with plugin events [#5](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/5)
* Flex Pages: Fixed `filePathClean()` and `filePathClean()` not returning file for folder
* Flex Pages: Fixed multiple multi-language related issues in admin [#10](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/10)
* Flex Pages: Fixed raw edit mode
* File upload is broken for nested fields [#34](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/34)
# v1.0.0-beta.10
## 10/03/2019
1. [](#bugfix)
* Flex Pages: Fixed moving visible page in admin causing ordering issues [#6](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/6)
* Flex Pages List: Fixed issue where auto-hiding scrollbars in macOS would throw off the dropdown position [#20](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/20)
* Flex Pages: Fixed prev/next page missing pages if pagination was turned on in page header
# v1.0.0-beta.9
## 09/26/2019
1. [](#improved)
* Show/hide dropdown menu as needed when scrolling the page columns container left and right
1. [](#bugfix)
* PHP 7.1: Fixed error when activating `Flex Pages` in Plugin parameters [#13](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/13)
* Flex Pages: Fixed page template cannot be changed [#4](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/4)
* Flex Pages: Fixed new pages being created with wrong template [#22](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/22)
* Flex Pages: Fixed `Preview` not working [#17](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/17)
* Fixed error caused by automatic path selection from cookie when destination not available [#23](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/23)
* Fixed breadcrumb issue in Flex Pages List [#19](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/19)
* Flex Pages: Fixed unable to change page template [#4](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/4)
* Fixed `Error 404` when adding new contact [#14](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/14)
* Flex Pages: Non-visible items appear in Nav menu [#24](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/24)
* Disabling plugin breaks saving plugin configuration [#11](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/11)
# v1.0.0-beta.8
## 09/19/2019
1. [](#new)
* Initial public release (all previous versions were in a private repo)

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018 Trilby Media, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,289 @@
# Flex Objects Plugin
## About
The **Flex Objects** Plugin is for [Grav CMS](https://github.com/getgrav/grav). Flex objects is a powerful new plugin that allows you to build custom collections of objects, which can modified by CRUD operations via the admin plugin to easily manage large sets of data that don't make sense as simple YAML configuration files, or Grav pages. These objects are defined by blueprints written in YAML and they are rendered by a set of twig files. Additionally both objects and collections can be customized by PHP classes, which allows you to define complex behaviors and relationships between the objects.
![](assets/flex-objects-list.png)
![](assets/flex-objects-edit.png)
![](assets/flex-objects-options.png)
## System Requirements
Plugin requires **Grav** v1.7.25 or later version in order to run. Additionally you need **Form Plugin** v5.1.0 and optionally **Admin Plugin** v1.10.25 or later version.
## Installation
Typically a plugin should be installed via [GPM](http://learn.getgrav.org/advanced/grav-gpm) (Grav Package Manager):
```
$ bin/gpm install flex-objects
```
Alternatively it can be installed via the [Admin Plugin](http://learn.getgrav.org/admin-panel/plugins)
## Sample Data
Once installed you can either create entries manually, or you can copy the sample data set:
```shell
$ mkdir -p user/data/flex-objects
$ cp user/plugins/flex-objects/data/flex-objects/contacts.json user/data/flex-objects/contacts.json
```
## Configuration
This plugin works out of the box, but provides several fields that make modifying and extending this plugin easier:
```yaml
enabled: true
built_in_css: true
extra_admin_twig_path: 'theme://admin/templates'
admin_list:
per_page: 15
order:
by: updated_timestamp
dir: desc
directories:
- 'blueprints://flex-objects/contacts.yaml'
- 'blueprints://flex-objects/pages.yaml'
- 'blueprints://flex-objects/user-accounts.yaml'
- 'blueprints://flex-objects/user-groups.yaml'
```
Simply edit the **Flex Objects** plugin options in the Admin plugin, or copy the `flex-objects.yaml` default file to your `user/config/plugins/` folder and edit the values there. Read below for more help on what these fields do and how they can help you modify the plugin.
Most interesting configuration option is `directories`, which contains list or blueprint files which will define the flex types.
## Displaying
![](assets/flex-objects-site.png)
just create a page called `flex-objects.md` or set the template of your existing page to `template: flex-objects`. This will use the `flex-objects.html.twig` file provided by the plugin.
```twig
---
title: Directory
flex:
directory: contacts
---
# Directory Example
```
If you do not specify `flex.directory` name in the page header, the page will list all directories instead of displaying entries from a single directory.
![](assets/flex-objects-directory.png)
# Modifications
This plugin is configured with a sample contacts directory with a few sample fields:
* published
* first_name
* last_name
* email
* website
* tags
These are probably not the exact fields you might want, so you will probably want to change them. This is pretty simple to do with Flex Objects, you just need to change the **Blueprints** and the **Twig Templates**. This can be achieved simply enough by copying some current files and modifying them.
Let's assume you simply want to add a new "Phone Number" field to the existing Data and remove the "Tags". These are the steps you would need to perform:
1. Copy the `blueprints/flex-objects/contacts.yaml` Blueprint file to another location, let's say `user/blueprints/flex-objects/`. The file can really be stored anywhere, but if you are using admin, it is best to keep the blueprint file where admin can automatically find it.
!!! **NOTE:** If you want to put the blueprints to `user/themes/yourtheme/blueprints`, you need to use the new blueprint folder structure from Grav 1.7. See [Plugin/Theme Blueprints](https://learn.getgrav.org/17/advanced/grav-development/grav-17-upgrade-guide#plugin-theme-blueprints-blueprints-yaml).
2. Edit the `user/blueprints/flex-objects/contacts.yaml` like so:
```yaml
title: Contacts
description: Simple contact directory with tags.
type: flex-objects
config:
admin:
list:
title: name
fields:
published:
field:
type: toggle
label: Publ
width: 8
last_name:
link: edit
first_name:
link: edit
email:
phone:
data:
storage:
class: 'Grav\Framework\Flex\Storage\SimpleStorage'
options:
formatter:
class: 'Grav\Framework\File\Formatter\JsonFormatter'
folder: user-data://flex-objects/contacts.json
form:
validation: loose
fields:
published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
required: true
last_name:
type: text
label: Last Name
validate:
required: true
first_name:
type: text
label: First Name
email:
type: email
label: Email Address
validate:
required: true
website:
type: url
label: Website URL
phone:
type: text
label: Phone Number
```
See how we replaced `tags:` with `phone:` in the `config.admin.list.fields` section at the top. Also, notice how we removed the `tags:` Blueprint field definition, and added a simple text field for `phone:`. If you have questions about available form fields, [check out the extensive documentation](https://learn.getgrav.org/forms/blueprints/fields-available) on the subject.
3. We need to copy the frontend Twig file and modify it to add the new "Phone" field. By default your theme already has its `templates`, so we can take advantage of it <sup>2</sup>. We'll simply copy the `user/plugins/flex-objects/templates/flex/contacts/object/default.html.twig` file to `user/themes/quark/templates/flex/contacts/object/default.html.twig`. Notice, there is no reference to `admin/` here, this is site template, not an admin one. We are also assuming you are using `Quark` theme, so you may have to change this to reference the theme you are using.
4. Edit the `default.html.twig` file you just copied so it has these modifications:
```twig
<div class="entry-details">
{% if object.website %}
<a href="{{ object.website }}"><span class="name">{{ object.last_name }}, {{ object.first_name }}</span></a>
{% else %}
<span class="name">{{ object.last_name }}, {{ object.first_name }}</span>
{% endif %}
{% if object.email %}
<p><a href="mailto:{{ object.email }}" class="email">{{ object.email }}</a></p>
{% endif %}
{% if object.phone %}
<p class="phone">{{ object.phone }}</p>
{% endif %}
</div>
```
Notice, we removed the `entry-extra` DIV, and added a new `if` block with the Twig code to display the phone number if set.
5. We also need to tweak the JavaScript initialization which provides which hooks up certain classes to the search. To do this we need to copy the `user/plugins/flex-objects/templates/flex/contacts/collection/default.html.twig` file to `user/themes/quark/templates/flex/contacts/collection/default.html.twig`. Notice this is the `collection` template this time, not the `object` template as we copied before.
Edit this file and replace the `<script></script>` tag at the bottom with this code:
```html
<script>
var options = {
valueNames: [ 'name', 'email', 'website', 'phone' ]
};
var userList = new List('flex-objects', options);
</script>
```
# File Upload
To upload files you can use the `file` form field. [The standard features apply](https://learn.getgrav.org/forms/blueprints/how-to-add-file-upload), and you can simply edit your custom blueprint with a field definition similar to:
```
item_image:
type: file
label: Item Image
random_name: true
destination: 'user/data/flex-objects/files'
multiple: true
```
> In order to fully take advantage of image uploads, you should always be using `FolderStorage`, meaning that the objects get saved to individual folders together with the images. Other storage layers may or may not support media.
# Advanced
You can radically alter the structure of the `contacts.json` data file by making major edits to the `contacts.yaml` blueprint file. However, it's best to start with an empty `contacts.json` if you are making wholesale changes or you will have data conflicts. Best to create your blueprint first. Reloading a **New Entry** until the form looks correct, then try saving, and check to make sure the stored `user/data/flex-objects/contacts.json` file looks correct.
Then you will need to make more widespread changes to the site Twig templates. You might need to adjust the number of columns and the field names. You will also need to pay attention to the JavaScript initialization in each template.
# Features
Here are the main benefits of using Flex objects:
* CRUD is automatically handled for you by Flex Objects plugin
* Objects can be stored using many different strategies, including single file, file per object or folder per object; using yaml, json etc.
* Flex types can be easily extended by custom PHP collection and object classes
* Both Flex objects and collections know how to render themselves: `echo $object->render($layout, $context)` or `{% render object layout: layout with context %}`
* You can easily create custom layouts for your objects and collections to be used in different pages
* Both Flex objects and collections support serialization and `json_encode()`
* Flex objects support Grav `Medium` objects with few lines of code
* Flex objects can have relations to other Flex objects with few lines of code defining the relation
* Flex directories support indexes which allow searching objects without loading all of them
* Efficient caching for indexes, searches, objects and rendered output
# Limitations and future improvements
Right now there are a few limitations:
* Frontend only has a basic routing for the individual pages (you need to do the advanced routing manually by yourself)
* Administration needs more features like filtering, bulk updates etc
* It would be nice to have an easy way to display Flex admin in other admin plugins (it is already possible, but not easy)
* Optional database storage layer would be nice to have
* We need general collection functions to do simple filtering, like: "display all published items" without custom PHP code
### Notes:
1. You can actually use pretty much any folder under the `user/` folder of Grav. Simply edit the **Extra Admin Twig Path** option in the `flex-objects.yaml` file. It defaults to `theme://admin/templates` which means it uses the default theme's `admin/templates/` folder if it exists.
2. You can use any path for front end Twig templates also, if you don't want to put them in your theme, you can add an entry in the **Extra Site Twig Path** option of the `flex-objects.yaml` configuration and point to another location.
# Tricks and tips
* You can enable and disable directories from **Plugins** > **Flex Objects**
* New Flex Directories can be registered by simply creating a new blueprint file in `user/blueprints/flex-objects` folder
* You can also add types from your plugins by hooking into `onFlexInit` event (see `AccountsServiceProvider` in Grav)
* To properly create your own custom types, you need at least the object blueprint and the template files for collections and objects
* Use `flex-objects.md` page to create entry point for your own directory
* In page header you can use nested `flex.directory` variable to define the directory (or do it in admin)
* In Admin you can just select the directory under the page title
# Parameters supported by Flex page type:
```
---
title: 'Flex Directories'
flex:
directories:
layout: default
list:
- accounts
- contacts
---
```
`directories.layout`: uses template file `templates/flex-objects/directories/[LAYOUT].html.twig`
`directories.list`: list of flex directories displayed in this page

View File

@@ -0,0 +1,7 @@
---
title: Flex Objects
access:
admin.flex-objects: true
admin.super: true
---

View File

@@ -0,0 +1,30 @@
{%- set user = admin.user -%}
{%- set route = controller.route -%}
{%- set type = directory.config('admin.template') ?? target -%}
{# Set action from ?preview=1 #}
{%- if key and uri.currentUri().queryParam('preview') %}
{% set action = 'preview' %}
{% endif -%}
{%- set template -%}
{%- if action == 'add' -%}
edit
{%- elseif action == 'delete' -%}
list
{%- else -%}
{{- action ?: task ?: 'types' -}}
{%- endif -%}
{%- endset -%}
{%- set separator = config.system.param_sep -%}
{%- set view_config = directory.config('admin.views.' ~ template) ?? directory.config('admin.' ~ template) ?? [] -%}
{%- include target ? [
'flex-objects/types/' ~ type ~ '/' ~ template ~ '.html.twig',
'flex-objects/types/default/' ~ template ~ '.html.twig',
'flex-objects/layouts/404.html.twig'
] : [
'flex-objects/types/default/' ~ template ~ '.html.twig',
'flex-objects/layouts/404.html.twig'
] -%}

View File

@@ -0,0 +1 @@
{{- admin.json_response|json_encode|raw -}}

View File

@@ -0,0 +1 @@
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.LAYOUT_NOT_FOUND'|tu(template, null) }}

View File

@@ -0,0 +1,25 @@
{% set active_html = 'class="active"' %}
{% set is_configure = route.gravParam('') is same as('configure') %}
{% set authorize = directory.config('admin.views.configure.authorize') ?? directory.config('admin.configure.authorize') ?? 'admin.super' %}
{% if allowed %}
<div class="form-tabs">
<div class="tabs-nav">
{% for name,title in {'user-accounts': 'PLUGIN_ADMIN.USERS', 'user-groups': 'PLUGIN_ADMIN.GROUPS'} %}
{% set current = flex.directory(name) %}
{% if current and current.isAuthorized('list', 'admin', user) %}
{% set active = not is_configure and nav_route|starts_with(flex.adminRoute(current)|trim('/') ~ '/') %}
<a {{active ? active_html|raw }} href="{{ admin_route(flex.adminRoute(current)) }}">
<span>{{ title|tu }}</span>
</a>
{% endif %}
{% endfor %}
{% if user.authorize(authorize) or user.authorize('admin.super') %}
<a {{is_configure ? active_html|raw }} href="{{ admin_route('/accounts/configure') }}">
<span>{{ 'PLUGIN_ADMIN.CONFIGURATION'|tu }}</span>
</a>
{% endif %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,3 @@
<a id="titlebar-button-add" class="button" href="{{ route.withGravParam('', 'add') }}">
<i class="fa fa-plus"></i> {{ 'PLUGIN_ADMIN.ADD'|tu }}
</a>

View File

@@ -0,0 +1,3 @@
<a id="titlebar-button-back" class="button" href="{{ back_url }}">
<i class="fa fa-reply"></i> {{ "PLUGIN_ADMIN.BACK"|tu }}
</a>

View File

@@ -0,0 +1,7 @@
{%- set authorize = directory.config('admin.views.configure.authorize') ?? directory.config('admin.configure.authorize') ?? 'admin.super' %}
{%- if configure_url and user.authorize(authorize) %}
<a id="titlebar-button-configure" class="button" href="{{ configure_url }}">
<i class="fa fa-cog"></i> {{ 'PLUGIN_ADMIN.CONFIGURATION'|tu }}
</a>
{% endif %}

View File

@@ -0,0 +1,3 @@
<a id="titlebar-button-delete" href="#delete" data-remodal-target="delete" data-delete-url="{{ uri.addNonce(route.withoutParams().withGravParam('task', 'delete').getUri(), 'admin-form', 'admin-nonce') }}" class="button danger">
<i class="fa fa-fw fa-trash-o"></i> {{ 'PLUGIN_ADMIN.DELETE'|tu }}
</a>

View File

@@ -0,0 +1,3 @@
<a id="titlebar-button-export-csv" class="button" href="{{ route.withGravParam('', 'csv') }}">
<i class="fa fa-download"></i> {{ 'PLUGIN_FLEX_OBJECTS.CSV'|tu }}
</a>

View File

@@ -0,0 +1,21 @@
{% if export.options %}
<div id="titlebar-button-export" class="button-group">
<button id="titlebar-button-export" type="button" class="button dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-download"></i> {{ 'PLUGIN_ADMIN.EXPORT'|tu }} <i class="fa fa-caret-down"></i>
</button>
<ul class="dropdown-menu lang-switcher">
{% for type,option in export.options %}
<li>
<a id="titlebar-button-export" class="button" href="{{ route.withGravParam('', 'export').withQueryParam('type', type) }}">
{{ option.title|tu }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<a id="titlebar-button-export" class="button" href="{{ route.withGravParam('', 'export') }}">
<i class="fa fa-download"></i> {{ export.title ?? (export.formatter.class ? 'PLUGIN_ADMIN.EXPORT'|tu : 'PLUGIN_FLEX_OBJECTS.CSV'|tu) }}
</a>
{% endif %}

View File

@@ -0,0 +1,18 @@
<div id="titlebar-languages" class="button-group">
<button type="button" class="button disabled">
<i class="fa fa-flag-o"></i>
{{ all_languages[language] ?? language }}
</button>
{% if admin_languages|length > (language in admin_languages)|int %}
<button type="button" class="button dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-caret-down"></i>
</button>
<ul class="dropdown-menu language-switcher">
{% for lang_code in admin_languages %}
{% if lang_code != language %}
<li><a href="{{ admin_route(route.getRoute(1), lang_code) }}">{{ all_languages[lang_code] ?? lang_code }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
</div>

View File

@@ -0,0 +1,5 @@
{% if preview_url %}
<a id="titlebar-button-preview-open" href="{{ preview_url }}" class="button" target="_blank">
<i class="fa fa-external-link"></i> {{ "PLUGIN_ADMIN.OPEN_NEW_TAB"|tu }}
</a>
{% endif %}

View File

@@ -0,0 +1,3 @@
<a id="titlebar-button-preview" href="{{ route.withGravParam('', 'preview') }}" class="button">
<i class="fa fa-eye"></i> {{ "PLUGIN_ADMIN.PREVIEW"|tu }}
</a>

View File

@@ -0,0 +1,4 @@
{% set task = task ?? 'save' %}
<button id="titlebar-button-save" class="button" type="submit" name="task" value="{{ task }}" form="blueprints" data-flex-safe-action>
<i class="fa fa-check"></i> {{ "PLUGIN_ADMIN.SAVE"|tu }}
</button>

View File

@@ -0,0 +1,103 @@
{% extends 'partials/base.html.twig' %}
{% use 'flex-objects/types/default/titlebar/configure.html.twig' %}
{% set name = view_config['form'] %}
{% set form = form ?? directory.directoryForm(name) %}
{# Allowed actions #}
{% set can_save = can_save ?? user.authorize(view_config['authorize'] ?? 'admin.super') %}
{# These variables can be overridden from the main template file #}
{% set allowed = allowed ?? (directory and form and can_save) %}
{% set back_route = back_route ?? ('/' ~ route.getRoute(1)) %}
{% set title_icon = title_icon ?? view_config['icon'] ?? 'fa-cog' %}
{% set title -%}
{%- set title_config = view_config['title'] ?? null -%}
{%- if title_config.template -%}
{{- include(template_from_string(title_config.template, 'configure title template')) -}}
{%- else -%}
{{- directory.title|tu }} - {{ 'PLUGIN_ADMIN.CONFIGURATION'|tu -}}
{% endif %}
{%- endset %}
{% macro spanToggle(input, length) %}
{{ (repeat('&nbsp;&nbsp;', (length - input|length) / 2) ~ input ~ repeat('&nbsp;&nbsp;', (length - input|length) / 2))|raw }}
{% endmacro %}
{% import _self as macro %}
{% block body %}
{% set back_url = back_url ?? admin_route(back_route) %}
{{ parent() }}
{% endblock body %}
{% block content_top %}
{% if allowed and user.authorize('admin.super') %}
{% set save_location = directory.getDirectoryConfigUri(name) %}
<div class="alert notice">{{ "PLUGIN_ADMIN.SAVE_LOCATION"|tu }}: <b>{{ url(save_location, false, true)|trim('/') }}</b></div>
{% endif %}
{% endblock %}
{% block topbar %}
{% if user.authorize('admin.super') %}
<form id="admin-mode-toggle">
{% set normalText = 'PLUGIN_ADMIN.NORMAL'|tu %}
{% set expertText = 'PLUGIN_ADMIN.EXPERT'|tu %}
{% set maxLen = max([normalText|length, expertText|length]) %}
{% set normalText = macro.spanToggle(normalText, maxLen) %}
{% set expertText = macro.spanToggle(expertText, maxLen) %}
<div class="switch-toggle switch-grav">
<input type="radio" value="normal" data-leave-url="{{ route.withGravParam('mode', 'normal').toString(true) }}" id="normal" name="mode-switch" class="highlight" {% if admin.session.expert == '0' %} checked="checked"{% endif %}>
<label for="normal">{{ normalText|raw }}</label>
<input type="radio" value="expert" data-leave-url="{{ route.withGravParam('mode', 'expert').toString(true) }}" id="expert" name="mode-switch" class="highlight" {% if admin.session.expert == '1' %} checked="checked"{% endif %}>
<label for="expert">{{ expertText|raw }}</label>
<a></a>
</div>
</form>
{% endif %}
{% endblock topbar %}
{% block content %}
{{ parent() }}
{% if allowed %}
<div class="clear directory admin-flex-config">
<div class="admin-form-wrapper">
{# TODO: RAW MODE
<div id="admin-topbar">
{{ block('topbar') }}
</div>
#}
{% block edit %}
{% include 'partials/blueprints.html.twig' with { form: form, data: form.data } %}
{% endblock %}
</div>
</div>
{% include 'partials/modal-changes-detected.html.twig' %}
{% else %}
{% do page.modifyHeader('http_response_code', 404) %}
<div class="error-block">
<h1>{{ 'PLUGIN_ADMIN.ERROR'|tu }} 404</h1>
<div class="padding">
<p>
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.PAGE_NOT_EXIST'|tu }}
</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block bottom %}
{{ parent() }}
{# TODO: RAW MODE
<script>
$('.admin-flex-config .form-tabs .tabs-nav').css('margin-right', ($('#admin-topbar').width() + 20) + 'px');
</script>
#}
{% endblock bottom %}

View File

@@ -0,0 +1,121 @@
{% extends 'partials/base.html.twig' %}
{% use 'flex-objects/types/default/titlebar/edit.html.twig' %}
{# Avoid defining form and object twice: object should always come from the form! #}
{% if form is not defined %}
{% set form = object.form %}
{% set object = form.object %}
{% endif %}
{# Allowed actions #}
{% set can_list = can_list ?? directory.isAuthorized('list', 'admin', user) %}
{% set can_read = can_read ?? (object.exists ? object.isAuthorized('read', 'admin', user) : directory.isAuthorized('create', 'admin', user)) %}
{% set can_create = can_create ?? object.isAuthorized('create', 'admin', user) %}
{% set can_save = can_save ?? (object.exists ? object.isAuthorized('update', 'admin', user) : directory.isAuthorized('create', 'admin', user)) %}
{% set can_delete = can_delete ?? (object.exists and object.isAuthorized('delete', 'admin', user)) %}
{% set can_translate = can_translate ?? (admin.multilang and object.hasFlexFeature('flex-translate')) %}
{% set can_preview = can_preview ?? (can_read and object.exists and (directory.config('admin.views.preview.enabled') ?? directory.config('admin.preview.enabled', false))) %}
{# Translations #}
{% if can_translate %}
{% set translate_include_default = translate_include_default ?? grav.config.get('system.languages.include_default_lang_file_extension', true) %}
{% set all_languages = grav.admin.siteLanguages %}
{% set admin_languages = admin.languages_enabled %}
{% set default_language = grav.language.default %}
{% set object_language = object.language %}
{% set language = controller.language %}
{% set has_translation = object.hasTranslation(language, false) %}
{#
{% if translate_include_default %}
{% set all_languages = all_languages|merge({'': 'Default'}) %}
{% set admin_languages = admin_languages|merge({'': ''}) %}
{% set object_languages = object.languages(true) %}
{% else %}
#}
{% set language = language ?: default_language %}
{% set object_language = object_language ?: default_language %}
{% set object_languages = object.languages(false) %}
{# endif #}
{% endif %}
{# These variables can be overridden from the main template file #}
{% set allowed = allowed ?? (directory and (object.exists and (can_read or can_save)) or (action == 'add' and can_read)) %}
{% set back_route = back_route ?? ('/' ~ (action != 'edit' and not key ? route.getRoute(1, not can_list ? -1 : null) : route.getRoute(1, not can_list ? -2 : -1))) %}
{% set title_icon = title_icon ?? view_config['icon'] ?? directory.config.admin.menu.list.icon ?? 'fa-file-text-o' %}
{% set title -%}
{%- set title_config = view_config['title'] -%}
{%- if title_config.template -%}
{{- include(template_from_string(title_config.template, 'edit title template')) -}}
{%- else -%}
{{- title ?? object.form.getValue('title') ?? object.title ?? key -}}
{% endif %}
{%- endset %}
{% block body %}
{% set back_url = back_url ?? admin_route(back_route) %}
{% set id = key %}
{% set blueprint = blueprint ?? form.blueprint %}
{{ parent() }}
{% endblock body %}
{% block content_top %}
{% if allowed and user.authorize('admin.super') %}
{% if directory and object or action == 'add' %}
{% set save_location = object.getStorageFolder() ?? directory.getStorageFolder() %}
<div class="alert notice">{{ "PLUGIN_ADMIN.SAVE_LOCATION"|tu }}: <b>{{ url(save_location, false, true)|trim('/') }} {{ not object.exists ? '[' ~ 'PLUGIN_FLEX_OBJECTS.NEW'|tu|upper ~ ']' }}</b></div>
{% endif %}
{% endif %}
{% if object.exists and form.flash.exists %}
<div class="alert secondary-accent">
<i class="fa fa-lightbulb-o"></i> {{ 'PLUGIN_FLEX_OBJECTS.STATE.EDITING_DRAFT'|tu }} <button class="button button-link" type="submit" name="task" value="reset" form="blueprints">{{ "PLUGIN_ADMIN.RESET"|tu }}</button>
</div>
{% endif %}
{% endblock %}
{% block content %}
{% if allowed %}
<div class="clear directory admin-{{ target }}">
<div class="admin-form-wrapper">
<div id="admin-topbar">
{% block topbar %}{% endblock %}
</div>
{% block edit %}
{% include 'partials/blueprints.html.twig' with { form: form, context: object, data: object } %}
{% endblock %}
</div>
</div>
{% include 'partials/modal-changes-detected.html.twig' %}
{% if can_delete %}
{% include ['flex-objects/types/' ~ target ~ '/modals/remove.html.twig', 'flex-objects/types/default/modals/remove.html.twig'] with { name: target } %}
{% endif %}
{% elseif (object.exists) %}
{% do page.modifyHeader('http_response_code', 403) %}
<div class="error-block">
<h1>{{ 'PLUGIN_ADMIN.ERROR'|tu }} 403</h1>
<div class="padding">
<p>
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.PAGE_FORBIDDEN'|tu }}
</p>
</div>
</div>
{% else %}
{% do page.modifyHeader('http_response_code', 404) %}
<div class="error-block">
<h1>{{ 'PLUGIN_ADMIN.ERROR'|tu }} 404</h1>
<div class="padding">
<p>
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.PAGE_NOT_EXIST'|tu }}
</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends 'partials/base.html.twig' %}
{% use 'flex-objects/types/default/titlebar/list.html.twig' %}
{# Allowed actions #}
{% set export = directory.config('admin.views.export') ?? directory.config('admin.export') ?? [] %}
{% set can_export = can_export ?? (export['enabled'] ?? export|array|count)|bool %}
{% set can_create = can_create ?? directory.isAuthorized('create', 'admin', user) %}
{% set can_translate = can_translate ?? (admin.multilang and directory.object.hasFlexFeature('flex-translate')) %}
{% set per_page = per_page ?? grav.uri.currentUri.queryParam('per_page') %}
{# Translations #}
{% if can_translate %}
{% set translate_include_default = translate_include_default ?? grav.config.get('system.languages.include_default_lang_file_extension', true) %}
{% set all_languages = grav.admin.siteLanguages %}
{% set admin_languages = admin.languages_enabled %}
{% set default_language = grav.language.default %}
{% set language = controller.language %}
{#
{% if translate_include_default %}
{% set all_languages = all_languages|merge({'': 'Default'}) %}
{% set admin_languages = admin_languages|merge({'': ''}) %}
{% else %}
#}
{% set language = language ?: default_language %}
{# endif #}
{% endif %}
{# These variables can be overridden from the main template file #}
{% set allowed = allowed ?? (directory and directory.isAuthorized('list', 'admin', user)) %}
{% set back_route = back_route ?? route.getRoute(1, -1) %}
{% set configure_path = directory.config('admin.router.actions.configure.path') %}
{% set configure_route = configure_route ?? (configure_path ? route.withRoute(admin_route(configure_path)|trim('/')) : null) %}
{% set configure_route = configure_route ?? route.withGravParam('', 'configure') %}
{% set title_icon = title_icon ?? view_config['icon'] ?? directory.config.admin.menu.list.icon ?? 'fa-file-text-o' %}
{% set title -%}
{%- set title_config = view_config['title'] ?? null -%}
{%- if title_config.template -%}
{{- include(template_from_string(title_config.template, 'configure title template')) -}}
{%- else -%}
{{- directory.title|tu -}}
{% endif %}
{%- endset %}
{% set schema = directory.blueprint.schema %}
{% do assets.addJs('plugin://flex-objects/js/flex-objects.js', { 'group': 'bottom', 'loading': 'defer' }) %}
{% block body %}
{% set collection = directory ? collection.isAuthorized('list', 'admin', user) %}
{% set directory_config = view_config['options'] ?? config.get('plugins.flex-objects.admin_list', { per_page: 15, order: { by: 'updated_timestamp', dir: 'desc' }}) %}
{% set per_page = max(1, per_page ?? directory_config.per_page) %}
{% set table = directory ? flex.dataTable(collection.flexDirectory(), { collection: collection, limit: per_page, sort: directory_config.order.by ~ '|' ~ directory_config.order.dir }) %}
{% set back_url = admin_route(back_route) %}
{% set configure_url = (directory.config('admin.views.configure.hidden') ?? directory.config('admin.configure.hidden', false)) is not same as(true) ? configure_route.toString(true) %}
{% set fields = table.getColumns() %}
{% set fields_count = fields ? count(fields) : 0 %}
{% set fields_width = 8 %}
{% set fields_set = 0 %}
{% set title_field = view_config['title'] %}
{% for key,options in fields %}
{% set fields_width = fields_width + options.width ?: 0 %}
{% set fields_set = fields_set + (options.width ? 1 : 0) %}
{% if not title_field and options.link == 'edit' %}
{% set title_field = key %}
{% endif %}
{% endfor %}
{{ parent() }}
{% endblock body %}
{% block content_top %}
{% if allowed and user.authorize('admin.super') %}
{% set save_location = directory.getStorageFolder() %}
<div class="alert notice">{{ "PLUGIN_ADMIN.SAVE_LOCATION"|tu }}: <b>{{ url(save_location, false, true)|trim('/') }}</b></div>
{% endif %}
{% endblock %}
{% block content %}
{% if allowed %}
{% block content_list %}
{% include ['flex-objects/types/' ~ target ~ '/list/list.html.twig', 'flex-objects/types/default/list/list.html.twig'] %}
{% endblock %}
{% else %}
{% do page.modifyHeader('http_response_code', 404) %}
<div class="error-block">
<h1>{{ 'PLUGIN_ADMIN.ERROR'|tu }} 404</h1>
<div class="padding">
<p>
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.PAGE_NOT_EXIST'|tu }}
</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% block directory %}
<div id="directory">
{% if not fields %}
{% block no_list %}
<div class="no-entries">
<p>{{ 'PLUGIN_FLEX_OBJECTS.ERROR.BLUEPRINT_NO_LIST'|tu( target, null )|raw }}</p>
<ul>
<li>
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.BLUEPRINT_NO_LIST_ADVISE'|tu }}
</li>
<li>
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.BLUEPRINT_NO_LIST_TEMPLATE'|tu( target, null )|raw }}
</li>
</ul>
</div>
{% endblock %}
{% elseif not collection.count %}
{% block no_entries %}
<div class="no-entries">
{% if directory.isAuthorized('create', 'admin', user) %}
{% set createLink = admin_route(flex.adminRoute(collection, {action: 'add'})) %}
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.LIST_EMPTY_ADD'|tu(createLink, null)|raw }}
{% else %}
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.LIST_EMPTY'|tu }}
{% endif %}
</div>
{% endblock %}
{% else %}
{% block entries %}
{% set per_page = per_page ?? directory_config.per_page %}
{% set tableFields = [] %}
{% set searchFields = [] %}
{% for key, options in fields %}
{% set name = key %}
{% set sortField = options.sort.field ?? key %}
{% set title = (options.field.label ?? schema.get(options.alias ?? key).label)|tu %}
{% set width = options.width ?: ((100-fields_width) / ((fields_count-fields_set) ?: 1))|round(3) %}
{% set title_class = options.title_class ?: '' %}
{% set data_class = options.data_class ?: '' %}
{# Vuetable doesn't like field names with `.` in them, so we convert name and sortField to `_` #}
{% set tableFields = tableFields|merge([
{
name: name|replace({'.': '_'}),
sortField: sortField,
title: title ?? 'N/A',
width: width ~ '%',
titleClass: title_class,
dataClass: data_class
}
]) %}
{# FIXME: Search fields should be passed and individually customizable, right now defaulting to all fields selected #}
{% set searchFields = searchFields|merge([key|replace({'.': '_'})]) %}
{% endfor %}
{% set tableFields = tableFields|merge([{ name: '_actions_', title: 'Actions', titleClass: 'right' }]) %}
{% set list = table.jsonSerialize %}
<div
id="flex-objects-list"
data-initial-store="{{
{
data: list,
api: grav.uri.currentRoute().withExtension('json').uri()|string,
perPage: per_page,
fields: tableFields,
searchFields: searchFields,
sortOrder: [{ field: directory_config.order.by, direction: directory_config.order.dir }],
searchPlaceholder: "PLUGIN_ADMIN.RESOURCE_FILTER"|tu,
paginationInfo: "PLUGIN_FLEX_OBJECTS.LIST_INFO"|tu,
emptyResult: "PLUGIN_FLEX_OBJECTS.EMPTY_RESULT"|tu
}|json_encode|e('html_attr')
}}">
<svg viewBox="0 0 1060 {{ 31 * (min(per_page, list.data|count) + 2) }}">
{% for i in 0..((min(per_page, list.data|count) + 3) - 1) %}
<rect clip-path="url(#clip-path-{{ i }})" x="0" y="0" width="1060" height="{{ 31 * per_page }}" style="fill: url(#linear-gradient-{{ i }})"></rect>
<defs>
<clipPath id="clip-path-{{ i }}">
<rect x="13" y="{{ 31 * i + 10 }}" rx="6" ry="6" width="{{ 200 * random([0.7, 0.8, 0.9, 1]) }}" height="12"></rect>
<rect x="533" y="{{ 31 * i + 10 }}" rx="6" ry="6" width="{{ 63 * random([0.7, 0.8, 0.9, 1]) }}" height="12"></rect>
<rect x="653" y="{{ 31 * i + 10 }}" rx="6" ry="6" width="{{ 78 * random([0.7, 0.8, 0.9, 1]) }}" height="12"></rect>
<rect x="755" y="{{ 31 * i + 10 }}" rx="6" ry="6" width="{{ 117 * random([0.7, 0.8, 0.9, 1]) }}" height="12"></rect>
<rect x="938" y="{{ 31 * i + 10 }}" rx="6" ry="6" width="{{ 83 * random([0.7, 0.8, 0.9, 1]) }}" height="12"></rect>
<rect x="0" y="{{ 31 * i }}" rx="6" ry="6" width="1060" height=".3"></rect>
</clipPath>
<linearGradient id="linear-gradient-{{ i }}">
<stop offset="-0.60016" stop-color="#d9d9d9" stop-opacity="1">
<animate attributeName="offset" values="-3; 1" dur="2s" repeatCount="indefinite"></animate>
</stop>
<stop offset="0.39984" stop-color="#ecebeb" stop-opacity="1">
<animate attributeName="offset" values="-2; 2" dur="2s" repeatCount="indefinite"></animate>
</stop>
<stop offset="1.39984" stop-color="#d9d9d9" stop-opacity="1">
<animate attributeName="offset" values="-1; 3" dur="2s" repeatCount="indefinite"></animate>
</stop>
</linearGradient>
</defs>
{% endfor %}
</svg>
</div>
{% endblock %}
{% endif %}
{% block modals %}
{% include 'flex-objects/types/default/modals/remove.html.twig' with { name: target } %}
{% endblock %}
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% set object_title = title_field ? "'" ~ object[title_field]|join(' ') ~ "'" : 'Item' %}
{% set can_read = object.isAuthorized('read', 'admin', user) %}
{% set can_update = object.isAuthorized('update', 'admin', user) %}
{% set can_delete = object.isAuthorized('delete', 'admin', user) %}
{% if can_read and object.getRoute() %}
{% block action_preview %}
<a href="{{ route.withAddedPath(object.getKey()).withoutParams().withQueryParam('preview', 1).getUri() }}" title="{{ "PLUGIN_ADMIN.PREVIEW"|tu }}" class="preview-action">
<i class="fa fa-eye"></i>
</a>
{% endblock %}
{% endif %}
{% if can_update %}
{% block action_edit %}
<a href="{{ route.withAddedPath(object.getKey()).withoutParams().getUri() }}" title="Edit {{ object_title }}" class="edit-action">
<i class="fa fa-pencil"></i>
</a>
{% endblock %}
{% elseif can_read %}
{% block action_read %}
<a href="{{ route.withAddedPath(object.getKey()).withoutParams().getUri() }}" title="View {{ object_title }}" class="edit-action">
<i class="fa fa-search"></i>
</a>
{% endblock %}
{% endif %}
{% if can_delete %}
{% block action_delete %}
<a href="#delete"
class="page-delete delete-action"
title="Delete {{ object_title }}"
data-remodal-target="delete"
data-delete-url="{{ grav.uri.addNonce(route.withAddedPath(object.getKey()).withoutParams().withGravParam('task', 'delete').getUri(), 'admin-form', 'admin-nonce') }}"
>
<i class="fa fa-close"></i>
</a>
{% endblock %}
{% endif %}

View File

@@ -0,0 +1,13 @@
<div class="remodal" data-remodal-id="delete" data-remodal-options="hashTracking: false">
<form>
{# FIXME -name|singularize- is not translatable #}
<h1>{{ 'PLUGIN_FLEX_OBJECTS.ACTION.DELETE_N'|tu }} {{ name|singularize|capitalize }}</h1>
<p class="bigger">
{{ 'PLUGIN_FLEX_OBJECTS.ACTION.REALLY_DELETE'|tu( name|singularize, null ) }}
</p>
<div class="button-bar">
<button data-remodal-action="cancel" class="button secondary remodal-cancel"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|tu }}</button>
<a class="button disable-after-click" data-delete-action href="#"><i class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|tu }}</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,62 @@
{% extends 'partials/base.html.twig' %}
{% use 'flex-objects/types/default/titlebar/preview.html.twig' %}
{# Allowed actions #}
{% set can_preview = can_preview ?? (object.exists and (view_config['enabled'] ?? false)) %}
{% set can_translate = can_translate ?? (admin.multilang and object.hasFlexFeature('flex-translate')) %}
{# These variables can be overridden from the main template file #}
{% set allowed = allowed ?? (directory and (object.exists or action == 'add')) %}
{% set back_route = back_route ?? ('/' ~ route.getRoute(1)) %}
{% set title_icon = title_icon ?? view_config['icon'] ?? directory.config.admin.menu.list.icon ?? 'fa-file-text-o' %}
{% set title -%}
{%- set title_config = view_config['title'] -%}
{%- if title_config.template -%}
{{- include(template_from_string(title_config.template, 'edit title template')) -}}
{%- else -%}
{{- title ?? object.form.getValue('title') ?? object.title ?? key -}}
{% endif %}
{%- endset %}
{% set preview_url -%}
{%- set route_config = view_config['route'] -%}
{%- if route_config.template -%}
{{- include(template_from_string(route_config.template, 'preview route template')) -}}
{%- else -%}
{{- preview_url ?? object.getRoute().uri ?: '' -}}
{%- endif -%}
{% endset -%}
{% block body %}
{% if not can_preview or not preview_url %}
{% set allowed = false %}
{% endif %}
{% set id = key %}
{% set blueprint = object.blueprint ?? directory.blueprint %}
{% set back_url = back_url ?? admin_route(back_route) %}
{{ parent() }}
{% endblock body %}
{% block content_wrapper %}
{% if can_preview and allowed and preview_url %}
<div class="content-wrapper preview-wrapper">
<div class="content-padding" width="100%" height="100%">
<iframe width="100%" height="100%" frameborder="0" src="{{ preview_url }}"></iframe>
</div>
</div>
{% else %}
{{ parent() }}
{% endif %}
{% endblock content_wrapper %}
{% block content %}
{% do page.modifyHeader('http_response_code', 404) %}
<div class="error-block">
<h1>{{ 'PLUGIN_ADMIN.ERROR'|tu }} 404</h1>
<div class="padding">
<p>
{{ 'PLUGIN_FLEX_OBJECTS.ERROR.PAGE_NOT_EXIST'|tu }}
</p>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,32 @@
{% block titlebar %}
{% block titlebar_button_bar %}
<div class="button-bar">
{# BACK #}
{% block back_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/back.html.twig', 'flex-objects/types/default/buttons/back.html.twig'] %}
{% endblock back_button %}
{% block extra_buttons %}{% endblock extra_buttons %}
{# SAVE #}
{% if can_save %}
{% block save_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/save.html.twig', 'flex-objects/types/default/buttons/save.html.twig'] with {task: 'configure'} %}
{% endblock save_button %}
{% endif %}
</div>
{% endblock titlebar_button_bar %}
{% block titlebar_title %}
<h1>
{% if allowed %}
<i class="fa fa-fw fa-cog"></i>
{{ title }}
{% else %}
<i class="fa fa-fw fa-exclamation-triangle"></i>
{{ 'PLUGIN_ADMIN.ERROR'|tu }}
  {% endif %}
</h1>
{% endblock titlebar_title %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% block titlebar %}
{% block titlebar_button_bar %}
<div class="button-bar">
{# BACK #}
{% block back_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/back.html.twig', 'flex-objects/types/default/buttons/back.html.twig'] %}
{% endblock back_button %}
{# PREVIEW #}
{% if can_preview %}
{% block preview_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/preview.html.twig', 'flex-objects/types/default/buttons/preview.html.twig'] %}
{% endblock preview_button %}
{% endif %}
{# DELETE #}
{% if can_delete %}
{% block delete_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/delete.html.twig', 'flex-objects/types/default/buttons/delete.html.twig'] %}
{% endblock delete_button %}
{% endif %}
{% block extra_buttons %}{% endblock extra_buttons %}
{# SAVE #}
{% if allowed and can_save %}
{% block save_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/save.html.twig', 'flex-objects/types/default/buttons/save.html.twig'] with {task: 'save'} %}
{% endblock save_button %}
{% endif %}
</div>
{% endblock titlebar_button_bar %}
{% block titlebar_title %}
<h1>
{% if allowed %}
<i class="fa fa-fw {{ title_icon }}"></i>
{{ not object.exists ? '[' ~ 'PLUGIN_FLEX_OBJECTS.NEW'|tu|upper ~ ']' }} {{ title|tu }}
{% else %}
<i class="fa fa-fw fa-exclamation-triangle"></i>
{{ 'PLUGIN_ADMIN.ERROR'|tu }}
{% endif %}
</h1>
{% endblock titlebar_title %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% block titlebar %}
{% block titlebar_button_bar %}
<div class="button-bar">
{# BACK #}
{% block back_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/back.html.twig', 'flex-objects/types/default/buttons/back.html.twig'] %}
{% endblock back_button %}
{# EXPORT #}
{% if can_export %}
{% block export_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/export.html.twig', 'flex-objects/types/default/buttons/export.html.twig'] with {export: directory.config('admin.views.export') ?? directory.config('admin.export') ?? []} %}
{% endblock export_button %}
{% endif %}
{# CREATE #}
{% if can_create %}
{% block create_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/add.html.twig', 'flex-objects/types/default/buttons/add.html.twig'] %}
{% endblock create_button %}
{% endif %}
{# LANGUAGES #}
{% if can_translate %}
{% block languages_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/languages.html.twig', 'flex-objects/types/default/buttons/languages.html.twig'] %}
{% endblock languages_button %}
{% endif %}
{# CONFIGURE #}
{% block configure %}
{% include 'flex-objects/types/default/buttons/configuration.html.twig' %}
{% endblock configure %}
</div>
{% endblock titlebar_button_bar %}
{% block titlebar_title %}
<h1>
{% if allowed %}
<i class="fa fa-fw {{ directory ? title_icon : 'fa-exclamation-triangle' }}"></i>
{{ directory ? title|tu : 'Error' }}
{% else %}
<i class="fa fa-fw fa-exclamation-triangle"></i>
{{ 'PLUGIN_ADMIN.ERROR'|tu }}
 {% endif %}
</h1>
{% endblock titlebar_title %}
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% block titlebar %}
{% block titlebar_button_bar %}
<div class="button-bar">
{# BACK #}
{% block back_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/back.html.twig', 'flex-objects/types/default/buttons/back.html.twig'] %}
{% endblock back_button %}
{# PREVIEW #}
{% if can_preview %}
{% block preview_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/preview-open.html.twig', 'flex-objects/types/default/buttons/preview-open.html.twig'] %}
{% endblock preview_button %}
{% endif %}
</div>
{% endblock titlebar_button_bar %}
{% block titlebar_title %}
<h1>
{% if allowed %}
<i class="fa fa-fw {{ title_icon }}"></i>
{{ "PLUGIN_ADMIN.PREVIEW"|tu }}: {{ not object.exists ? '[' ~ 'PLUGIN_FLEX_OBJECTS.NEW'|tu|upper ~ ']' }} <strong>{{ title }}</strong>
{% else %}
<i class="fa fa-fw fa-exclamation-triangle"></i>
{{ 'PLUGIN_ADMIN.ERROR'|tu }}
{% endif %}
</h1>
{% endblock titlebar_title %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% block titlebar %}
{% block titlebar_button_bar %}
<div class="button-bar">
{# BACK #}
{% block back_button %}
{% include 'flex-objects/types/default/buttons/back.html.twig' %}
{% endblock back_button %}
{# CONFIGURE #}
{% block configure %}
{% include 'flex-objects/types/default/buttons/configuration.html.twig' %}
{% endblock configure %}
</div>
{% endblock titlebar_button_bar %}
{% block titlebar_title %}
<h1>
<i class="fa fa-fw fa-list"></i>
{{ "PLUGIN_FLEX_OBJECTS.TITLE"|tu }}
</h1>
{% endblock titlebar_title %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends 'partials/base.html.twig' %}
{% use 'flex-objects/types/default/titlebar/types.html.twig' %}
{% set flex = grav['flex_objects'] %}
{# These variables can be overridden from the main template file #}
{% set back_route = back_route ?? ('/' ~ route.getRoute(1, -1)) %}
{% set configure_route = '/plugins/flex-objects' %}
{% block body %}
{% set back_url = admin_route(back_route) %}
{% set configure_url = configure_route ? admin_route(configure_route) : null %}
{{ parent() }}
{% endblock body %}
{% block content %}
<h1>{{ 'PLUGIN_FLEX_OBJECTS.TYPES_TITLE'|tu }}</h1>
<div id="types" class="card-row grid fixed-blocks pure-g">
{% for name,directory in flex.directories if directory.enabled and directory.config('admin.hidden', false) is not same as(true) and not directory.config('admin.menu') %}
{% try %}
{% set collection = directory.collection %}
{% if flex.adminRoute(collection) %}
<div class="card-item pure-u-1-3">
<h2><a href="{{ admin_route(flex.adminRoute(collection)) }}">{{ directory.title|tu }}</a> <span class="badge">{{ collection.isAuthorized('list', 'admin', user).count }}</span></h2>
<p>
{{ directory.description }}
</p>
</div>
{% endif %}
{% catch %}
<div class="card-item pure-u-1-3">
<h2>{{ 'PLUGIN_FLEX_OBJECTS.ERROR.BAD_DIRECTORY'|tu }} '{{ name }}'</h2>
<p>
{{ e.message }}
</p>
</div>
{% endcatch %}
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
<div id="titlebar-add" class="button-group">
<button type="button" class="button disabled" href="#modal" data-remodal-target="modal">
<i class="fa fa-plus"></i> {{ "PLUGIN_ADMIN.ADD"|tu }}
</button>
<button type="button" class="button dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-caret-down"></i>
</button>
<ul class="dropdown-menu">
<li><a class="button" href="#modal" data-remodal-target="modal">{{ "PLUGIN_ADMIN.ADD_PAGE"|tu }}</a></li>
<li><a class="button" href="#modal-folder" data-remodal-target="modal-folder">{{ "PLUGIN_ADMIN.ADD_FOLDER"|tu }}</a></li>
{% if admin.modularTypes is not empty %}
<li><a class="button" href="#module" data-remodal-target="module">{{ "PLUGIN_ADMIN.ADD_MODULE"|tu }}</a></li>
{% endif %}
{% for key, add_modal in config.plugins.admin.add_modals %}
{% if add_modal.show_in|defined('bar') == 'dropdown' %}
<li><a class="button {{ add_modal.link_classes }}" href="#modal-add_modal-{{ key }}" data-remodal-target="modal-add_modal-{{ key }}">{{ add_modal.label|tu }}</a></li>
{% endif %}
{% endfor %}
</ul>
</div>

View File

@@ -0,0 +1,3 @@
<a id="titlebar-button-back" class="button" href="{{ back_url }}" title="{{ "PLUGIN_ADMIN.BACK"|tu }}">
<i class="fa fa-reply"></i>
</a>

View File

@@ -0,0 +1,4 @@
{# href="{{ uri.addNonce(route.withoutParams().withGravParam('task', 'copy').getUri(), 'admin-form', 'admin-nonce') }}" #}
<a id="titlebar-button-copy" href="#modal-page-copy" data-remodal-target="modal-page-copy" class="button page-copy">
<i class="fa fa-copy"></i> {{ "PLUGIN_ADMIN.COPY"|tu }}
</a>

View File

@@ -0,0 +1,3 @@
<a id="titlebar-button-delete" href="#delete" data-remodal-target="delete" data-delete-url="{{ uri.addNonce(route.withoutParams().withGravParam('task', 'delete').getUri(), 'admin-form', 'admin-nonce') }}" class="button danger">
<i class="fa fa-fw fa-trash-o"></i> {{ "PLUGIN_ADMIN.DELETE"|tu }}
</a>

View File

@@ -0,0 +1,6 @@
<a id="titlebar-button-move" class="button" href="#" data-remodal-target="move" data-parents="data[route]">
<i class="fa fa-arrows"></i> {{ "PLUGIN_ADMIN.MOVE"|tu }}
</a>
<div class="remodal parents-container" data-remodal-id="move" data-remodal-options="hashTracking: false">
{% include 'partials/page-move.html.twig' with { blueprints: admin.blueprints('admin/pages/move'), data: context } %}
</div>

View File

@@ -0,0 +1,9 @@
{% if child_url %}
<a id="titlebar-button-nav-child" class="button" href="{{ child_url }}" title="{{ title }}">
<i class="fa fa-chevron-down"></i>
</a>
{% else %}
<span class="button disabled">
<i class="fa fa-chevron-down"></i>
</span>
{% endif %}

View File

@@ -0,0 +1,9 @@
{% if next_url %}
<a id="titlebar-button-nav-next" class="button" href="{{ next_url }}" title="{{ title }}">
<i class="fa fa-chevron-right"></i>
</a>
{% else %}
<span class="button disabled">
<i class="fa fa-chevron-right"></i>
</span>
{% endif %}

View File

@@ -0,0 +1,9 @@
{% if parent_url %}
<a id="titlebar-button-nav-parent" class="button" href="{{ parent_url }}" title="{{ title }}">
<i class="fa fa-chevron-up"></i>
</a>
{% else %}
<span class="button disabled">
<i class="fa fa-chevron-up"></i>
</span>
{% endif %}

View File

@@ -0,0 +1,9 @@
{% if prev_url %}
<a id="titlebar-button-nav-prev" class="button" href="{{ prev_url }}" title="{{ title }}">
<i class="fa fa-chevron-left"></i>
</a>
{% else %}
<span class="button disabled">
<i class="fa fa-chevron-left"></i>
</span>
{% endif %}

View File

@@ -0,0 +1,5 @@
{% if object.routable and object.published %}
<a id="titlebar-button-preview" href="{{ route.withGravParam('', 'preview') }}" class="button" title="{{ "PLUGIN_ADMIN.PREVIEW"|tu }}">
<i class="fa fa-eye"></i>
</a>
{% endif %}

View File

@@ -0,0 +1,23 @@
{% set task = task ?? 'save' %}
<div id="titlebar-save" class="button-group">
<button class="button success" type="submit" name="task" value="{{ task }}" lang="{{ language }}" form="blueprints">
<i class="fa fa-check"></i> {{ "PLUGIN_ADMIN.SAVE"|tu }}
</button>
{% if can_translate %}
{% set untranslated = admin_languages|array_diff(object_languages|merge([language])) %}
{% if count(untranslated) %}
<button id="titlebar-button-save" type="button" class="button success dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-caret-down"></i>
</button>
<ul class="dropdown-menu lang-switcher">
{% for lang_code in untranslated %}
<li>
<button class="button success task" type="submit" name="task" value="saveas" lang="{{ lang_code }}" form="blueprints">
{{ "PLUGIN_ADMIN.SAVE_AS"|tu }} {{ all_languages[lang_code] ?? lang_code }}
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,236 @@
{% extends 'flex-objects/types/default/edit.html.twig' %}
{# Avoid defining form and object twice: object should always come from the form! #}
{% set expert = user.authorize('admin.super') and admin.session.expert != '0' %}
{% if expert or form is not defined %}
{% set form = object.form(expert ? 'raw' : '') %}
{% set object = form.object %}
{% endif %}
{% set title = title ?? form.getValue('header.title') ?? object.title ?? key %}
{% set parent = object.parent %}
{% set can_read = can_read ?? (object.exists ? object.isAuthorized('read', 'admin', user) : object.isAuthorized('create', 'admin', user))|bool %}
{% set can_copy = can_copy ?? (parent.exists and parent.isAuthorized('create', 'admin', user)) %}
{% set can_create = can_create ?? (object.exists and object.isAuthorized('create', 'admin', user)) %}
{% set can_save = can_save ?? (object.exists ? object.isAuthorized('update', 'admin', user) : object.isAuthorized('create', 'admin', user))|bool %}
{% set can_move = can_move ?? can_save and form.blueprint.schema.property('route').type is same as('parents') %}
{% set can_translate = can_translate ?? (admin.multilang and object.hasFlexFeature('page-translate') and not object.root()) %}
{% macro spanToggle(input, length) %}
{{ (repeat('&nbsp;&nbsp;', (length - input|length) / 2) ~ input ~ repeat('&nbsp;&nbsp;', (length - input|length) / 2))|raw }}
{% endmacro %}
{% import _self as macro %}
{% block body %}
{% set current_route = '/' ~ route.getRoute(1) %}
{% if not object.root() %}
{% set child = object.children.first %}
{% set prev = object.prevSibling %}
{% set next = object.nextSibling %}
{% set parent_url = parent and not parent.root ? admin_route(back_route) %}
{% set child_url = can_read and child ? admin_route(current_route ~ '/' ~ child.slug) %}
{% set prev_url = can_read and prev ? admin_route(back_route ~ '/' ~ prev.slug) %}
{% set next_url = can_read and next ? admin_route(back_route ~ '/' ~ next.slug) %}
{% endif %}
{% set back_url = back_url ?? admin_route(flex.adminRoute(directory.getFlexType())) %}
{{ parent() }}
{% endblock body %}
{% block back_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/back.html.twig', 'flex-objects/types/pages/buttons/back.html.twig']
with { back_url: back_url } %}
{% if not object.root() %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/nav-prev.html.twig', 'flex-objects/types/pages/buttons/nav-prev.html.twig']
with { prev_url: prev_url, title: prev.route } %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/nav-parent.html.twig', 'flex-objects/types/pages/buttons/nav-parent.html.twig']
with { parent_url: parent_url, title: parent.route } %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/nav-child.html.twig', 'flex-objects/types/pages/buttons/nav-child.html.twig']
with { child_url: child_url, title: child.route } %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/nav-next.html.twig', 'flex-objects/types/pages/buttons/nav-next.html.twig']
with { next_url: next_url, title: next.route } %}
{% endif %}
{% endblock back_button %}
{% block preview_button %}
{% if object.exists and not object.root() %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/preview.html.twig', 'flex-objects/types/pages/buttons/preview.html.twig'] %}
{% endif %}
{% endblock preview_button %}
{% block delete_button %}
{# FIXME: add support for deleting root file only #}
{% if not object.root() %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/delete.html.twig', 'flex-objects/types/pages/buttons/delete.html.twig'] %}
{% endif %}
{% endblock delete_button %}
{% block extra_buttons %}
{% if object.exists and not object.root() %}
{% if can_create %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/add.html.twig', 'flex-objects/types/pages/buttons/add.html.twig'] %}
{% endif %}
{% if can_copy %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/copy.html.twig', 'flex-objects/types/pages/buttons/copy.html.twig'] %}
{% endif %}
{% if can_move %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/move.html.twig', 'flex-objects/types/pages/buttons/move.html.twig'] %}
{% endif %}
{% endif %}
{% endblock extra_buttons %}
{% block save_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/save.html.twig', 'flex-objects/types/pages/buttons/save.html.twig'] %}
{% endblock save_button %}
{% block content_top %}
{% if allowed and user.authorize('admin.super') %}
<div class="alert notice">
{% set save_location = object.getStorageFolder() ?: directory.getStorageFolder() %}
{{ "PLUGIN_ADMIN.SAVE_LOCATION"|tu }}: <b>{{ url(save_location, false, true)|trim('/') }} {{ not object.exists ? '[' ~ 'PLUGIN_FLEX_OBJECTS.NEW'|tu|upper ~ ']' }}</b> (type: <b>{{ (form.getValue('name') ?: 'default') }}</b>)
</div>
{% endif %}
{% if object.exists and form.flash.exists %}
<div class="alert secondary-accent">
<i class="fa fa-lightbulb-o"></i> {{ 'PLUGIN_FLEX_OBJECTS.STATE.EDITING_DRAFT'|tu }} <button class="button button-link" type="submit" name="task" value="reset" form="blueprints">{{ "PLUGIN_ADMIN.RESET"|tu }}</button>
</div>
{% endif %}
{% if not object.exists %}
<div class="alert secondary-accent">
<i class="fa fa-lightbulb-o"></i> {{ 'PLUGIN_FLEX_OBJECTS.STATE.NOT_CREATED_YET'|tu }}
</div>
{% elseif can_translate %}
{% set is_default = language is same as(default_language) %}
{% if is_default and default_language in object_languages %}
{% if not translate_include_default and object.property('lang') %}
{# Handle default language extension #}
<div class="alert secondary-accent">
{% set overrideLanguage = all_languages[object_language] ?? object_language %}
{{ 'PLUGIN_FLEX_OBJECTS.LANGUAGE.USING_OVERRIDE'|tu(overrideLanguage, null)|raw }}
{{ object.hasTranslation('', false) ? 'PLUGIN_FLEX_OBJECTS.LANGUAGE.UNUSED_DEFAULT'|tu|raw }}
</div>
{% elseif translate_include_default %}
{% if not object.property('lang') %}
<div class="alert secondary-accent">
{{ 'PLUGIN_FLEX_OBJECTS.LANGUAGE.USING_DEFAULT'|tu|raw }}
</div>
{% elseif object.hasTranslation('', false) %}
<div class="alert secondary-accent">
{{ 'PLUGIN_FLEX_OBJECTS.LANGUAGE.UNUSED_DEFAULT'|tu|raw }}
</div>
{% endif %}
{% endif %}
{% elseif not has_translation %}
<div class="alert warning">
{% set overrideLanguage = all_languages[language] ?? object_language %}
{{ 'PLUGIN_FLEX_OBJECTS.LANGUAGE.NOT_TRANSLATED_YET'|tu(overrideLanguage, null)|raw }}
{% if language == object_language %}
{{ 'PLUGIN_FLEX_OBJECTS.LANGUAGE.NO_FALLBACK_FOUND'|tu|raw }}
{% else %}
{% set overrideLanguage = all_languages[object_language] ?? object_language %}
{{ 'PLUGIN_FLEX_OBJECTS.LANGUAGE.FALLING_BACK'|tu(overrideLanguage, null)|raw }}
{% endif %}
</div>
{% endif %}
{% endif %}
{% endblock content_top %}
{% block topbar %}
{% if can_translate %}
<div id="admin-lang-toggle" class="button-group">
<button type="button" class="button disabled">
<i class="fa fa-flag-o"></i>
{{ all_languages[object_language] ?? object_language }}
</button>
{% if count(object_languages) > (object_language in object_languages)|int %}
<button type="button" class="button dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-caret-down"></i>
</button>
<ul class="dropdown-menu language-switcher">
{% for lang_code in object_languages %}
{% if lang_code != object_language %}
<li>
<a href="{{ admin_route(route.getRoute(1), lang_code) }}">{{ all_languages[lang_code] ?? lang_code }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
{% if user.authorize('admin.super') %}
<form id="admin-mode-toggle">
{% set normalText = 'PLUGIN_ADMIN.NORMAL'|tu %}
{% set expertText = 'PLUGIN_ADMIN.EXPERT'|tu %}
{% set maxLen = max([normalText|length, expertText|length]) %}
{% set normalText = macro.spanToggle(normalText, maxLen) %}
{% set expertText = macro.spanToggle(expertText, maxLen) %}
<div class="switch-toggle switch-grav">
<input type="radio" value="normal" data-leave-url="{{ route.withGravParam('mode', 'normal').toString(true) }}" id="normal" name="mode-switch" class="highlight" {% if expert == '0' %} checked="checked"{% endif %}>
<label for="normal">{{ normalText }}</label>
<input type="radio" value="expert" data-leave-url="{{ route.withGravParam('mode', 'expert').toString(true) }}" id="expert" name="mode-switch" class="highlight" {% if expert == '1' %} checked="checked"{% endif %}>
<label for="expert">{{ expertText }}</label>
<a></a>
</div>
</form>
{% endif %}
{% endblock topbar %}
{% block edit %}
{% include 'partials/blueprints.html.twig' with { context: object, data: object, blueprints: form.blueprint } %}
{% endblock edit %}
{% block content %}
{{ parent() }}
{% include 'partials/modal-changes-detected.html.twig' %}
{% if object.exists %}
{% set modal_data = data({
route: '/' ~ object.key,
name: object.header.child_type ?? object.blueprint.child_type ?? 'default'
}) %}
<div class="remodal" data-remodal-id="modal" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include 'partials/blueprints-new.html.twig' with { form: null, blueprints: admin.blueprints('admin/pages/new'), data: modal_data, form_id: 'new-page' } %}
</div>
<div class="remodal" data-remodal-id="modal-folder" data-remodal-options="hashTracking: false">
{% include 'partials/blueprints-new-folder.html.twig' with { form: null, blueprints: admin.blueprints('admin/pages/new_folder'), data: modal_data, form_id: 'new-folder' } %}
</div>
<div class="remodal" data-remodal-id="module" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include 'partials/blueprints-new.html.twig' with { form: null, blueprints: admin.blueprints('admin/pages/modular_new'), data: modal_data, form_id: 'new-module' } %}
</div>
<div class="remodal" data-remodal-id="modal-page-copy" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include 'partials/blueprints-copy.html.twig' with { blueprints: admin.blueprints('admin/pages/copy'), data: data({ title: object.title ~ ' (Copy)', folder: object.slug ~ '-copy' }), form_id: 'copy' } %}
</div>
{% endif %}
{# TODO: regular pages support extra modals from admin config #}
<div class="remodal parents-container" data-remodal-id="parents" data-remodal-options="hashTracking: false, stack: true">
<form>
<h1>{{ 'PLUGIN_FLEX_OBJECTS.PARENTS'|tu }}</h1>
<div class="grav-loading"><div class="grav-loader">{{ 'PLUGIN_FLEX_OBJECTS.STATE.LOADING'|tu }}</div></div>
<div class="parents-content"></div>
<div class="button-bar">
<a class="button secondary remodal-cancel" data-remodal-action="cancel" href="#"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|tu }}</a>
<a class="button" data-parents-select href="#"><i class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|tu }}</a>
</div>
</form>
</div>
{% endblock content %}
{% block bottom %}
{{ parent() }}
<script>
$('.admin-pages .form-tabs .tabs-nav').css('margin-right', ($('#admin-topbar').width() + 20) + 'px');
</script>
{% endblock bottom %}

View File

@@ -0,0 +1,29 @@
{% extends 'flex-objects/types/default/list.html.twig' %}
{% set can_create = true %}
{% block back_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/back.html.twig', 'flex-objects/types/pages/buttons/back.html.twig'] %}
{% endblock back_button %}
{% block create_button %}
{% for key, add_modal in config.plugins.admin.add_modals %}
{% if add_modal.show_in|default('bar') == 'bar' %}
<a class="button {{ add_modal.link_classes }}" href="#modal-add_modal-{{ key }}" data-remodal-target="modal-add_modal-{{ key }}"><i class="fa fa-plus"></i> {{ add_modal.label|tu }}</a>
{% endif %}
{% endfor %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/add.html.twig', 'flex-objects/types/pages/buttons/add.html.twig'] %}
{% endblock %}
{% block content_top %}{% endblock %}
{% block content_list %}
{% set list_layout = grav.uri.param('layout', 'columns') %}
{% include [
'flex-objects/types/' ~ target ~ '/list/' ~ list_layout ~ '.html.twig',
'flex-objects/types/pages/list/' ~ list_layout ~ '.html.twig',
'flex-objects/types/' ~ target ~ '/list/list.html.twig',
'flex-objects/types/pages/list/list.html.twig'
] %}
{% endblock %}

View File

@@ -0,0 +1,155 @@
{% macro toggle(id, title, filters, name = null) %}
{% set name = id|fieldName %}
{% set filter = filters[name] ?? null %}
{% set value = filter is null ? 0 : (not filter)|int+1 %}
{% set classes = ['status-unchecked', 'status-checked', 'status-indeterminate'] %}
<span class="checkboxes indeterminate toggleable {{ classes[value] }}" data-_check-status="{{ value }}">
<input type="checkbox" id="{{ id }}" name="{{ name }}" indeterminate="{{ value == 2 ? 'true' : 'false' }}" value="{{ filter }}" {% if value == 1 %}checked{% endif %}>
<label for="{{ id }}">{{ title }}</label>
</span>
{% endmacro %}
{% import _self as macros %}
{% block directory %}
{% set filters = grav.request.getCookieParams()['grav-admin-flexpages']|base64_decode|json_decode(true)['filters'] %}
{% set hidePanel = filters|length == 0 or (filters|length == 1 and filters['filters[search]']) %}
<div id="pages-content-wrapper">
<div id="pages-filters">
<form>
<div class="filters-bar">
<input type="text"
placeholder="{{ 'PLUGIN_FLEX_OBJECTS.ACTION.SEARCH_PLACEHOLDER'|tu }}"
name="filters[search]"
value="{{ filters['filters[search]'] }}" />
<a href="#" class="adv-options button-border {{ hidePanel ? 'close' : 'open' }}">
{{ 'PLUGIN_FLEX_OBJECTS.ACTION.ADVANCED_OPTIONS'|tu }}
</a>
</div>
<div class="filters-advanced {{ hidePanel ? 'hide' }}">
<fieldset>
<legend>
{{ 'PLUGIN_FLEX_OBJECTS.FILTER.PAGE_ATTRIBUTES'|tu }}
</legend>
{{ macros.toggle('filters.routable', 'Routable', filters) }}
{{ macros.toggle('filters.module', 'Module', filters) }}
{{ macros.toggle('filters.visible', 'Visible', filters) }}
{{ macros.toggle('filters.published', 'Published', filters) }}
{{ macros.toggle('filters.translated', 'Translated', filters) }}
{{ macros.toggle('filters.folder', 'Empty Folder', filters) }}
</fieldset>
{% set selected = filters['filters[page_type]']|split(',') %}
{% set page_types = admin.types(null) %} {# directory.config('filters.ignore_page_types') #}
<fieldset>
<legend>
{{ 'PLUGIN_FLEX_OBJECTS.FILTER.PAGE_TYPES'|tu }}
</legend>
{% for name,title in page_types %}
<span class="checkboxes toggleable">
<input type="checkbox" id="filters.type.{{ name }}" name="filters[page_type][]" value="{{ name }}" {% if name in selected %}checked{% endif %}>
<label for="filters.type.{{ name }}">{{ title }}</label>
</span>
{% endfor %}
</fieldset>
{% set module_types = admin.modularTypes(null) %} {# directory.config('filters.ignore_module_types') #}
{% if module_types %}
<fieldset>
<legend>
{{ 'PLUGIN_FLEX_OBJECTS.FILTER.MODULAR_TYPES'|tu }}
</legend>
{% for name,title in module_types %}
<span class="checkboxes toggleable">
<input type="checkbox" id="filters.type.{{ name }}" name="filters[page_type][]" value="{{ name }}" {% if name in selected %}checked{% endif %}>
<label for="filters.type.{{ name }}">{{ title }}</label>
</span>
{% endfor %}
</fieldset>
{% endif %}
<a href="#" class="apply-filters button-border" data-filters="apply">
{{ 'PLUGIN_FLEX_OBJECTS.ACTION.APPLY_FILTERS'|tu }}
</a>
<a href="#" class="reset-defaults button-border" data-filters="reset">
{{ 'PLUGIN_FLEX_OBJECTS.ACTION.RESET_FILTERS'|tu }}
</a>
</div>
</form>
</div>
<div class="grav-loading">
<div class="grav-loader">{{ 'PLUGIN_FLEX_OBJECTS.STATE.LOADING'|tu }}</div>
</div>
<div id="pages-columns" style="margin-top: -1rem;"
data-lang-url="{{ grav.uri.getCurrentRoute.withoutParams.withExtension('').withLanguage('%LANG%').toString(true) }}"></div>
</div>
{# Modals #}
<div class="remodal" data-remodal-id="modal" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include 'partials/blueprints-new.html.twig' with { blueprints: admin.blueprints('admin/pages/new'), data: obj_data, form_id: 'new-page' } %}
</div>
<div class="remodal" data-remodal-id="modal-folder" data-remodal-options="hashTracking: false">
{% include 'partials/blueprints-new-folder.html.twig' with { blueprints: admin.blueprints('admin/pages/new_folder'), data: obj_data, form_id: 'new-folder' } %}
</div>
<div class="remodal" data-remodal-id="module" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include 'partials/blueprints-new.html.twig' with { blueprints: admin.blueprints('admin/pages/modular_new'), data: obj_data, form_id: 'new-module' } %}
</div>
{% for key, add_modal in config.plugins.admin.add_modals %}
<div class="remodal {{ add_modal.modal_classes|defined('') }}" data-remodal-id="modal-add_modal-{{ key }}" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include add_modal.template|defined('partials/blueprints-new.html.twig') with {
blueprints: admin.blueprints(add_modal.blueprint),
data: obj_data,
form_id: 'add-modal'
}|merge(add_modal.with|defined({})) %}
</div>
{% endfor %}
<div class="remodal" data-remodal-id="modal-page-copy" data-remodal-options="hashTracking: false">
{% include 'partials/blueprints-copy.html.twig' with { blueprints: admin.blueprints('admin/pages/copy'), data: obj_data, form_id: 'copy' } %}
</div>
<div class="remodal parents-container" data-remodal-id="parents"
data-remodal-options="hashTracking: false, stack: true">
<form>
<h1>Parents</h1>
<div class="grav-loading">
<div class="grav-loader">{{ 'PLUGIN_FLEX_OBJECTS.STATE.LOADING'|tu }}</div>
</div>
<div class="parents-content"></div>
<div class="button-bar">
<a class="button secondary remodal-cancel" data-remodal-action="cancel" href="#">
<i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|tu }}</a>
<a class="button" data-parents-select href="#">
<i class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|tu }}</a>
</div>
</form>
</div>
<div class="remodal" data-remodal-id="delete" data-remodal-options="hashTracking: false">
<form>
<h1>{{ "PLUGIN_ADMIN.MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_TITLE"|tu }}</h1>
<p class="bigger">
{% if context %}
<strong>{{ "PLUGIN_ADMIN.PAGE"|tu }}: {{ context.title }}</strong>
{% endif %}
</p>
<p class="bigger">
{{ "PLUGIN_ADMIN.MODAL_DELETE_PAGE_CONFIRMATION_REQUIRED_DESC"|tu }}
</p>
<br>
<div class="button-bar">
<button data-remodal-action="cancel" class="button secondary remodal-cancel"><i
class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|tu }}</button>
<a class="button disable-after-click" data-delete-action href="#"><i
class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|tu }}</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends 'flex-objects/types/default/list/list.html.twig' %}
{% block modals %}
<div class="remodal" data-remodal-id="modal" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include 'partials/blueprints-new.html.twig' with { blueprints: admin.blueprints('admin/pages/new'), data: obj_data, form_id: 'new-page' } %}
</div>
<div class="remodal" data-remodal-id="modal-folder" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include 'partials/blueprints-new-folder.html.twig' with { blueprints: admin.blueprints('admin/pages/new_folder'), data: obj_data, form_id: 'new-folder' } %}
</div>
<div class="remodal" data-remodal-id="module" data-remodal-options="hashTracking: false">
{% include 'partials/blueprints-new.html.twig' with { blueprints: admin.blueprints('admin/pages/modular_new'), data: obj_data, form_id: 'new-module' } %}
</div>
{% for key, add_modal in config.plugins.admin.add_modals %}
<div class="remodal {{ add_modal.modal_classes|defined('') }}" data-remodal-id="modal-add_modal-{{ key }}" data-remodal-options="hashTracking: false, closeOnOutsideClick: false">
{% include add_modal.template|defined('partials/blueprints-new.html.twig') with {
blueprints: admin.blueprints(add_modal.blueprint),
data: obj_data,
form_id: 'add-modal'
}|merge(add_modal.with|defined({})) %}
</div>
{% endfor %}
<div class="remodal" data-remodal-id="modal-page-copy" data-remodal-options="hashTracking: false">
{% include 'partials/blueprints-copy.html.twig' with { blueprints: admin.blueprints('admin/pages/copy'), data: obj_data, form_id: 'copy' } %}
</div>
<div class="remodal parents-container" data-remodal-id="parents" data-remodal-options="hashTracking: false, stack: true">
<form>
<h1>Parents</h1>
<div class="grav-loading"><div class="grav-loader">{{ 'PLUGIN_FLEX_OBJECTS.STATE.LOADING'|tu }}</div></div>
<div class="parents-content"></div>
<div class="button-bar">
<a class="button secondary remodal-cancel" data-remodal-action="cancel" href="#"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|tu }}</a>
<a class="button" data-parents-select href="#"><i class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|tu }}</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends 'flex-objects/types/default/preview.html.twig' %}
{% set can_translate = can_translate ?? (admin.multilang and object.hasFlexFeature('page-translate')) %}
{% block back_button %}
{% include ['flex-objects/types/' ~ target ~ '/buttons/back.html.twig', 'flex-objects/types/pages/buttons/back.html.twig']
with { back_url: back_url } %}
{% endblock back_button %}
{% block body %}
{% set parent = object.parent %}
{% set preview_url = preview_url ?: (object.home ? '/' : '') %}
{{ parent() }}
{% endblock body %}

View File

@@ -0,0 +1,9 @@
{% extends 'flex-objects/types/default/configure.html.twig' %}
{% set back_route = back_route ?? ('/' ~ route.getRoute(1, -1)) %}
{% block content_top %}
{% include 'flex-objects/layouts/accounts/partials/top.html.twig' %}
{{ parent() }}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends 'flex-objects/types/default/edit.html.twig' %}
{% if not directory.isAuthorized('list', 'admin', user) %}
{% set back_route = '/' %}
{% endif %}
{% if not object.exists %}
{% do object.onPrepareRegistration() %}
{% endif %}

View File

@@ -0,0 +1,7 @@
{% extends 'flex-objects/types/default/list.html.twig' %}
{% block content_top %}
{% include 'flex-objects/layouts/accounts/partials/top.html.twig' %}
{{ parent() }}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends 'flex-objects/types/default/configure.html.twig' %}
{% set back_route = back_route ?? ('/' ~ route.getRoute(1, -1)) %}
{% block content_top %}
{% include 'flex-objects/layouts/accounts/partials/top.html.twig' %}
{{ parent() }}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends 'flex-objects/types/default/list.html.twig' %}
{% block content_top %}
{% include 'flex-objects/layouts/accounts/partials/top.html.twig' %}
{{ parent() }}
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "forms/field.html.twig" %}
{% macro spanToggle(input, length) %}
{% set space = repeat('&nbsp;&nbsp;', (length - input|length) / 2) %}
{{ (space ~ input ~ space)|raw }}
{% endmacro %}
{% import _self as macro %}
{% set value = (value is null ? field.default : value) %}
{% block global_attributes %}
{{ parent() }}
data-grav-field-name="{{ (scope ~ field.name)|fieldName }}"
{% endblock %}
{% block input %}
{% set flex = grav['flex_objects'] %}
{% set all = flex.blueprints %}
{% if all|count %}
{% set legacy = flex.getLegacyBlueprintMap() %}
{% for label, directory in all %}
{% set url = directory.blueprintFile %}
{% set found = url in value %}
{% if not found and legacy[url] is defined %}
{% set found = legacy[url] in value %}
{% endif %}
<div class="form-data block size-2-3" data-grav-field="toggle" data-grav-disabled="" data-grav-default="null" data-grav-field-name="{{ (scope ~ field.name)|fieldName }}[{{ loop.index0 }}]">
<div class="switch-toggle switch-grav switch-2">
{% set maxLen = 0 %}
{% for text in ['PLUGIN_ADMIN.ENABLED', 'PLUGIN_ADMIN.DISABLED'] %}
{% set translation = grav.twig.twig.filters['tu'] is defined ? text|tu : text|t %}
{% set maxLen = max(translation|length, maxLen) %}
{% endfor %}
{% set id = "toggle_" ~ field.name ~ '_' ~ label %}
<input type="radio"
value="{{ url }}"
id="{{ id ~ '_yes' }}"
name="{{ (scope ~ field.name)|fieldName }}[{{ loop.index0 }}]"
class="highlight"
{% if found %}
checked="checked"
{% endif %}
/>
{% set text = 'PLUGIN_ADMIN.ENABLED' %}
{% set translation = (grav.twig.twig.filters['tu'] is defined ? text|tu : text|t)|trim %}
<label for="{{ id ~ '_yes' }}">{{ (macro.spanToggle(translation, maxLen)|trim)|raw }}</label>
<input type="radio"
value=""
name="{{ (scope ~ field.name)|fieldName }}[{{ loop.index0 }}]"
id="{{ id ~ '_no' }}"
{% if not found %}
checked="checked"
{% endif %}
/>
{% set text = 'PLUGIN_ADMIN.DISABLED' %}
{% set translation = (grav.twig.twig.filters['tu'] is defined ? text|tu : text|t)|trim %}
<label for="{{ id ~ '_no' }}">{{ (macro.spanToggle(translation, maxLen)|trim)|raw }}</label>
</div>
<span title="{{ directory.description|tu }}">{{ directory.title|tu }}</span>
</div>
{% endfor %}
{% else %}
<div>{{ 'PLUGIN_FLEX_OBJECTS.ERROR.NO_FLEX_DIRECTORIES'|tu }}</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "forms/field.html.twig" %}
{% set originalValue = value %}
{% set value = (value is null ? field.default : value) %}
{% set isNew = key ? false : true %}
{% set savedOption = grav.session.post_entries_save|default('create-new') %}
{% if isNew %}
{% set options = {'create-new':'PLUGIN_FLEX_OBJECTS.ACTION.CREATE_NEW', 'edit':'PLUGIN_FLEX_OBJECTS.ACTION.EDIT_ITEM', 'list':'PLUGIN_FLEX_OBJECTS.ACTION.LIST_ITEMS'} %}
{% else %}
{% set options = {'edit':'PLUGIN_FLEX_OBJECTS.ACTION.EDIT_ITEM', 'list':'PLUGIN_FLEX_OBJECTS.ACTION.LIST_ITEMS'} %}
{% endif %}
{% block input %}
{% set savedOption = not isNew and savedOption == 'create-new' ? 'edit' : savedOption %}
{% for key, text in options %}
{% set id = field.id|default(field.name) ~ '-' ~ key %}
{% if savedOption == key %}
{% set value = savedOption %}
{% endif %}
<span class="radio">
<input type="radio"
value="{{ key|e }}"
id="{{ id|e }}"
name="{{ (field.name)|fieldName }}"
{% if key == value %}checked="checked" {% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if field.validate.required in ['on', 'true', 1] %}required="required"{% endif %}
/>
<label style="display: inline" class="inline" for="{{ id|e }}">{{ text|tu|e }}</label>
</span>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,425 @@
import $ from 'jquery';
import Finder from '../utils/finder';
import { getInitialRoute, getStore, setInitialRoute } from './index';
// import getFilters from '../utils/get-filters';
let XHRUUID = 0;
const GRAV_CONFIG = typeof global.GravConfig !== 'undefined' ? global.GravConfig : global.GravAdmin.config;
export const Instances = {};
const isInViewport = (elem) => {
const bounding = elem.getBoundingClientRect();
const titlebar = document.querySelector('#titlebar');
const offset = titlebar ? titlebar.getBoundingClientRect().height : 0;
return (
bounding.top >= offset &&
bounding.left >= 0 &&
bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
);
};
export class FlexPages {
constructor(container, data) {
this.container = $(container);
this.data = data;
const dataLoad = this.dataLoad;
this.finder = new Finder(
this.container,
(parent, callback) => {
return dataLoad.call(this, parent, callback);
},
{
labelKey: 'title',
defaultPath: getInitialRoute(),
itemTrigger: '[data-flexpages-expand]',
createItem: function(item) {
return FlexPages.createItem(this.config, item, this);
},
createItemContent: function(item) {
return FlexPages.createItemContent(this.config, item, this);
}
}
);
this.finder.$emitter.on('leaf-selected', (item) => {
setInitialRoute({
route: item.route.raw
});
});
this.finder.$emitter.on('interior-selected', (item) => {
setInitialRoute({
route: item.route.raw
});
});
/*
this.finder.$emitter.on('leaf-selected', (item) => {
console.log('selected', item);
this.finder.emit('create-column', () => this.createSimpleColumn(item));
});
this.finder.$emitter.on('item-selected', (selected) => {
console.log('selected', selected);
// for future use only - create column-card creation for file with details like in macOS finder
// this.finder.$emitter('create-column', () => this.createSimpleColumn(selected));
}); */
this.finder.$emitter.on('column-created', () => {
this.container[0].scrollLeft = this.container[0].scrollWidth - this.container[0].clientWidth;
});
}
static createItem(config, item, finder) {
const listItem = $('<li />');
const listItemClasses = [config.className.item];
// const href = `${GRAV_CONFIG.current_url}/${item.route.raw}`.replace('//', '/');
const link = $('<div class="fjs-item-wrapper" />');
const createItemContent = config.createItemContent || finder.createItemContent;
const fragment = createItemContent.call(this, item);
link.append(fragment)
// .attr('href', href)
.attr('tabindex', -1);
if (item.url) {
link.attr('href', item.url);
listItemClasses.push(item.className);
}
if (item[config.childKey]) {
listItemClasses.push(config.className[config.childKey]);
}
if (item.filters_hit) {
listItemClasses.push('filters-hit');
}
listItem.addClass(listItemClasses.join(' '));
listItem.append(link)
.attr('data-fjs-item', item[config.itemKey]);
listItem[0]._item = item;
return listItem;
}
static createItemContent(config, item) {
const frag = document.createDocumentFragment();
const route = `${GRAV_CONFIG.current_url}/${item.route.raw}`.replace('//', '/');
const title = $('<div class="fjs-title" />');
const link = $(`<a href="${route}" />`);
const icon = $(`<span class="fjs-icon ${item.icon} badge-${item.extras && item.extras.published ? 'published' : 'unpublished'}" />`);
if (item.extras && item.extras.lang) {
let status = '';
if (item.extras.translated) {
status = 'translated';
}
if (item.extras.lang === 'n/a') {
status = 'not-available';
}
const lang = $(`<span class="badge-lang ${status}">${item.extras.lang}</span>`);
lang.appendTo(icon);
}
if (item.extras && item.extras && (item.extras.published_date || item.extras.unpublished_date)) {
const clock = $('<span class="badge-clock" />');
clock.appendTo(icon);
}
const info = $(`<span class="fjs-info"><b title="${item.title}">${item.title}</b> <em title="${item.route.display}">${item.route.display}</em></span>`);
const actions = $('<span class="fjs-actions" />');
let dotdotdot = null;
if (item.extras) {
const LANG_URL = $('[data-lang-url]').data('langUrl');
dotdotdot = $('<div class="button-group" data-flexpages-dotx3 data-flexpages-prevent><button class="button dropdown-toggle" data-toggle="dropdown"><i class="fa fa-ellipsis-v fjs-action-toggle"></i></button></div>');
dotdotdot.on('click', (event) => {
if (!dotdotdot.find('.dropdown-menu').length) {
let tags = '';
let langs = '';
item.extras.tags.forEach((tag) => {
tags += `<span class="badge tag tag-${tag}">${tag}</span>`;
});
const translations = item.extras.langs || {};
Object.keys(translations).forEach((lang) => {
const translated = translations[lang];
langs += `<a class="lang" href="${LANG_URL.replace(/%LANG%/g, lang).replace('//', '/')}${item.route.raw}"><span class="badge lang-${lang ? lang : 'default'} lang-${translated ? 'translated' : 'non-translated'}"><i class="fa fa-fw fa-circle"></i> ${lang ? lang : 'default'}</span></a>`;
});
const canPreview = item.extras.actions.includes('preview') && (!(item.extras.tags.includes('non-routable') || item.extras.tags.includes('unpublished')));
const canEdit = item.extras.actions.includes('edit');
const canCopy = item.extras.actions.includes('copy');
const canMove = false; // item.extras.actions.includes('move');
const canDelete = item.extras.actions.includes('delete');
const ul = $(`<div class="dropdown-menu">
<div class="action-bar">
${canPreview ? `<a href="${route}/:preview" class="dropdown-item" title="Preview"><i class="fa fa-fw fa-eye"></i></a>` : ''}
${canEdit ? `<a href="${route}" class="dropdown-item" title="Edit"><i class="fa fa-fw fa-pencil"></i></a>` : ''}
${canCopy ? `<a href="${route}/task:copy/admin-nonce:${GRAV_CONFIG.admin_nonce}" class="dropdown-item" title="Duplicate" href="#modal-page-copy" data-remodal-target="modal-page-copy" data-copy-flex-page data-title="${item.title}" data-folder="${item['item-key']}"><i class="fa fa-fw fa-copy"></i></a>` : ''}
${canMove ? '<a href="#" class="dropdown-item" title="Move (coming soon)"><i class="fa fa-fw fa-arrows"></i></a>' : ''}
${canDelete ? `<a href="#delete" data-remodal-target="delete" data-delete-url="${route}/task:delete/admin-nonce:${GRAV_CONFIG.admin_nonce}" class="dropdown-item danger" title="Delete"><i class="fa fa-fw fa-trash-o"></i></a>` : ''}
</div>
<div class="divider"></div>
<div class="tags">${tags}</div>
<div class="divider"></div>
${item.extras.lang || typeof item.extras.langs !== 'undefined' ? `<div class="langs">${langs}</div><div class="divider"></div>` : ''}
<div class="details">
<div class="infos">
<table>
<tr>
<td><b>route</b></td>
<td>${item.route.display}</td>
</tr>
<tr>
<td><b>template</b></td>
<td>${item.extras.template}</td>
</tr>
${item.extras && item.extras.published_date ? `
<tr>
<td><b>publish</b></td>
<td>${item.extras.published_date}</td>
</tr>
` : ''}
${item.extras && item.extras.unpublished_date ? `
<tr>
<td><b>unpublish</b></td>
<td>${item.extras.unpublished_date}</td>
</tr>
` : ''}
<tr>
<td><b>modified</b></td>
<td>${item.modified}</td>
</tr>
</table>
</div>
</div>
</div>`);
ul.appendTo(dotdotdot);
}
return true;
});
}
if (item.child_count) {
const button = $('<button class="fjs-children" data-flexpages-expand data-flexpages-prevent />');
const count = $(`<span class="badge child-count">${typeof item.count !== 'undefined' ? `${item.count} / ` : ''}${item.child_count}</span>`);
const arrow = $('<i class="fa fa-chevron-right"></i>');
count.appendTo(button);
arrow.appendTo(button);
button.appendTo(actions);
}
icon.appendTo(title);
dotdotdot.appendTo(title);
link.appendTo(title);
info.appendTo(link);
title.appendTo(frag);
actions.appendTo(frag);
return frag;
}
static createLoadingColumn() {
return $(`
<div class="fjs-col leaf-col" style="overflow: hidden;">
<div class="leaf-row">
<div class="grav-loading"><div class="grav-loader">Loading...</div></div>
</div>
</div>
`);
}
static createErrorColumn(error) {
return $(`
<div class="fjs-col leaf-col" style="overflow: hidden;">
<div class="leaf-row error">
<i class="fa fa-fw fa-warning"></i>
<span>${error}</span>
</div>
</div>
`);
}
createSimpleColumn(item) {}
dataLoad(parent, callback, filters = getStore().filters || {}) {
/* if (!parent && Object.keys(filters).length) {
parent = { child_count: 1, route: { raw: '' } };
}*/
if (!parent) {
return callback(this.data);
}
if (!parent.child_count) {
return false;
}
const UUID = ++XHRUUID;
this.startLoader();
const withFilters = Object.keys(filters).length ? { ...filters } : {};
$.ajax({
url: `${GRAV_CONFIG.current_url}`,
method: 'post',
data: Object.assign({}, {
route: b64_encode_unicode(parent.route.raw),
action: 'listLevel'
}, withFilters),
success: (response) => {
this.stopLoader();
if (response.status === 'error') {
this.finder.$emitter.emit('create-column', FlexPages.createErrorColumn(response.message)[0]);
return false;
}
// stale request
if (UUID !== XHRUUID) {
return false;
}
if (response.data.length) {
parent.children = response.data;
}
return callback(response.data);
}
});
}
startLoader() {
if (!this.finder) {
return null;
}
this.loadingIndicator = FlexPages.createLoadingColumn();
this.finder.$emitter.emit('create-column', this.loadingIndicator[0]);
return this.loadingIndicator;
}
stopLoader() {
return this.loadingIndicator && this.loadingIndicator.remove();
}
}
export const b64_encode_unicode = (str) => {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
function toSolidBytes(match, p1) {
return String.fromCharCode('0x' + p1);
}));
};
export const b64_decode_unicode = (str) => {
return decodeURIComponent(atob(str).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
};
const updatePosition = (scrollingColumn, pageColumns) => {
const group = document.querySelector('#pages-columns .button-group.open');
if (group) {
const button = group.querySelector('[data-toggle="dropdown"]');
const dropdown = group.querySelector('.dropdown-menu');
const buttonInView = isInViewport(button);
if (button && dropdown) {
if (!buttonInView) {
$(dropdown).css({ display: 'none' });
} else {
$(dropdown).css({ display: 'inherit' });
const buttonClientRect = button.getBoundingClientRect();
const dropdownClientRect = dropdown.getBoundingClientRect();
const scrollTop = (window.pageYOffset || document.documentElement.scrollTop);
const scrollLeft = (window.pageXOffset || document.documentElement.scrollLeft);
const top = buttonClientRect.height + buttonClientRect.top + scrollTop;
let left = buttonClientRect.left + scrollLeft; // - dropdownClientRect.width
if (left + dropdownClientRect.width > window.innerWidth) {
left = window.innerWidth - dropdownClientRect.width - 5;
}
$(dropdown).css({ top, left });
if (scrollingColumn) {
const targetClientRect = event.target.getBoundingClientRect();
if ((top < targetClientRect.top + scrollTop) || (top > targetClientRect.top + scrollTop + targetClientRect.height)) {
$(dropdown).css({ display: 'none' });
}
}
if (pageColumns) {
const targetClientRect = event.target.getBoundingClientRect();
if ((left < targetClientRect.left + scrollLeft) || (left > targetClientRect.left + scrollLeft + targetClientRect.width)) {
$(dropdown).css({ display: 'none' });
}
}
}
}
}
};
const closeGhostDropdowns = () => {
const opened = document.querySelectorAll('#pages-columns .button-group:not(.open) .dropdown-menu') || [];
opened.forEach((item) => { item.style.display = 'none'; });
};
document.addEventListener('scroll', (event) => {
if (event.target && !event.target.classList) { return true; }
const scrollingDocument = event.target.classList.contains('gm-scroll-view') || event.target.classList.contains('content-wrapper');
const scrollingColumn = event.target.classList.contains('fjs-col');
const pageColumns = event.target.id === 'pages-columns';
if (scrollingDocument || scrollingColumn || pageColumns) {
closeGhostDropdowns();
updatePosition(scrollingColumn, pageColumns);
}
}, true);
document.addEventListener('click', (event) => {
closeGhostDropdowns();
if (event.target.dataset.toggle || event.target.closest('[data-toggle="dropdown"]')) {
const containerScroller = document.querySelectorAll('.gm-scroll-view');
((containerScroller.length ? containerScroller : document.querySelectorAll('.content-wrapper')) || []).forEach((scroll) => {
const scrollEvent = new Event('scroll');
scroll.dispatchEvent(scrollEvent);
});
}
if ((event.target.classList && event.target.classList.contains('dropdown-menu')) || (event.target.closest('.dropdown-menu'))) {
if (!$(event.target).closest('.dropdown-menu').find(event.target).length) {
event.preventDefault();
event.stopPropagation();
}
}
if (event.target.dataset.copyFlexPage || event.target.closest('[data-copy-flex-page]')) {
const target = event.target.dataset.copyFlexPage ? event.target : event.target.closest('[data-copy-flex-page]');
const modal = document.querySelector('[data-remodal-id="modal-page-copy"]');
const form = modal.querySelector('form');
const titleField = modal.querySelector('[name="data[title]"]');
const folderField = modal.querySelector('[name="data[folder]"]');
titleField.value = `${target.dataset.title} (Copy)`;
folderField.value = `${target.dataset.folder}-copy`;
form.action = target.href;
}
});
// Prevent dropdowns from closing when clicking within
$(document).on('click.bs.dropdown.data-api', '.fjs-item-wrapper .dropdown-menu', (event) => {
event.stopPropagation();
});

View File

@@ -0,0 +1,102 @@
import $ from 'jquery';
import { b64_decode_unicode, b64_encode_unicode, FlexPages } from './finder';
import { isEnabled, getCookie, setCookie } from 'tiny-cookie';
import getFilters from '../utils/get-filters';
const container = document.querySelector('#pages-content-wrapper');
export const getStore = () => {
if (!isEnabled) {
return '';
}
return JSON.parse(b64_decode_unicode(getCookie('grav-admin-flexpages') || 'e30='));
};
export const setStore = (store = {}, options = { expires: '1Y', samesite: 'Lax' }) => {
if (!isEnabled) {
return '';
}
return setCookie('grav-admin-flexpages', b64_encode_unicode(JSON.stringify(store)), options);
};
export const getInitialRoute = () => {
const parsed = getStore();
return parsed.route || '';
};
export const setInitialRoute = ({ route = '', filters = getStore().filters || {}, options = { expires: '1Y' }} = {}) => {
return setStore({ route, filters }, options);
};
export let FlexPagesInstance = null;
export const ReLoad = (fresh = false) => {
const search = document.querySelector('#pages-filters [name="filters[search]"]');
const loader = container.querySelector('.grav-loading');
const content = container.querySelector('#pages-columns');
const gravConfig = typeof global.GravConfig !== 'undefined' ? global.GravConfig : global.GravAdmin.config;
if (fresh && search) {
search.focus();
}
if (loader && content) {
loader.style.display = 'block';
content.innerHTML = '';
const filters = fresh ? getStore().filters || {} : getFilters();
const withFilters = Object.keys(filters).length ? { ...filters, initial: true } : {};
const store = getStore();
store.filters = filters;
setStore(store);
let isSearchFocused = false;
if (search) {
isSearchFocused = search === document.activeElement;
}
const contentWrapper = document.querySelector('.content-wrapper .gm-scroll-view');
const scrollPosition = {
top: contentWrapper ? contentWrapper.scrollTop : 0,
left: contentWrapper ? contentWrapper.scrollLeft : 0
};
$.ajax({
url: `${gravConfig.current_url}`,
method: 'post',
data: Object.assign({}, {
route: b64_encode_unicode(getInitialRoute()),
initial: true,
action: 'listLevel'
}, withFilters),
success(response) {
loader.style.display = 'none';
if (response.status === 'error') {
content.innerHTML = response.message;
return true;
}
FlexPagesInstance = null;
FlexPagesInstance = new FlexPages(content, response.data);
if (search && isSearchFocused) {
search.focus();
}
if (contentWrapper) {
contentWrapper.scrollTo(scrollPosition);
}
return FlexPagesInstance;
}
});
}
};
if (container) {
ReLoad(true);
}

View File

@@ -0,0 +1,46 @@
import '../utils/indeterminate';
import './panel';
import { ReLoad } from '../columns';
import throttle from 'lodash/throttle';
document.addEventListener('click', (event) => {
const filterType = event.target && event.target.dataset.filters;
if (filterType === 'reset') {
const filters = event.target.closest('#pages-filters');
(filters.querySelectorAll('input[type="text"]') || []).forEach((input) => {
input.value = '';
});
(filters.querySelectorAll('input[type="checkbox"]') || []).forEach((input) => {
const wrapper = input.closest('.checkboxes');
if (wrapper) {
wrapper.classList.remove('status-checked', 'status-unchecked', 'status-indeterminate');
wrapper.dataset._checkStatus = '0';
wrapper.classList.add('status-unchecked');
}
input.indeterminate = false;
input.checked = false;
input.value = '';
});
return false;
}
if (filterType === 'apply') {
ReLoad();
return false;
}
});
const throttledReload = throttle(() => {
ReLoad();
}, 350, { leading: false });
document.addEventListener('input', (event) => {
if (event.target.getAttribute && event.target.getAttribute('name') === 'filters[search]') {
throttledReload.cancel();
throttledReload();
}
});

View File

@@ -0,0 +1,15 @@
const toggle = document.querySelector('.filters-bar .adv-options');
const panel = document.querySelector('.filters-advanced');
if (toggle && panel) {
document.addEventListener('click', (event) => {
if (event.target.classList.contains('adv-options') || event.target.closest('.adv-options')) {
event.preventDefault();
const isOpen = toggle.classList.contains('open');
panel.classList.toggle('hide');
toggle.classList.remove(isOpen ? 'open' : 'close');
toggle.classList.add(isOpen ? 'close' : 'open');
}
});
}

View File

@@ -0,0 +1,27 @@
<template>
<div>
<flex-filter-bar :store="store" />
<flex-content-loader :store="store" v-show="loading" />
<flex-table :store="store" v-model="loading" v-show="!loading" />
</div>
</template>
<script>
import FlexTable from './components/Table.vue';
import FlexFilterBar from './components/FilterBar.vue';
import FlexContentLoader from './components/ContentLoader.vue';
export default {
props: ['initialStore'],
components: {FlexTable, FlexFilterBar, FlexContentLoader},
data: () => ({
perPage: 10,
loading: true
}),
computed: {
store() {
return JSON.parse(this.initialStore || '{}');
}
}
}
</script>

View File

@@ -0,0 +1,29 @@
export default {
table: {
tableClass: 'table',
loadingClass: 'loading',
sortableIcon: '',
ascendingIcon: 'fa fa-fw fa-chevron-up',
descendingIcon: 'fa fa-fw fa-chevron-down',
ascendingClass: '',
descendingClass: '',
handleIcon: 'fa fa-fw fa-bars',
renderIcon: (classes, options) => `<i class="${classes.join(' ')}"></i>`
},
pagination: {
wrapperClass: 'flex-objects-pagination',
activeClass: 'button active',
disabledClass: 'button disabled',
pageClass: 'button page',
linkClass: 'button link',
icons: {
first: 'fa fa-fw fa-angle-double-left',
prev: 'fa fa-fw fa-chevron-left',
next: 'fa fa-fw fa-chevron-right',
last: 'fa fa-fw fa-angle-double-right'
}
},
paginationInfo: {
infoClass: ''
}
};

View File

@@ -0,0 +1,48 @@
<template>
<div>
<div :style="{ height: 300, width: '100%' }"></div>
<content-loader
:height="fixedAmount * count"
:width="1060"
:speed="2"
primaryColor="#d9d9d9"
secondaryColor="#ecebeb"
>
<template v-for="index in count">
<rect x="13" :y="fixedAmount * index + offset" rx="6" ry="6" :width="200 * random()" height="12" />
<rect x="533" :y="fixedAmount * index + offset" rx="6" ry="6" :width="63 * random()" height="12" />
<rect x="653" :y="fixedAmount * index + offset" rx="6" ry="6" :width="78 * random()" height="12" />
<rect x="755" :y="fixedAmount * index + offset" rx="6" ry="6" :width="117 * random()" height="12" />
<rect x="938" :y="fixedAmount * index + offset" rx="6" ry="6" :width="83 * random()" height="12" />
<rect x="0" :y="fixedAmount * index" rx="6" ry="6" width="1060" height=".3" />
</template>
</content-loader>
</div>
</template>
<script>
import { ContentLoader } from 'vue-content-loader';
export default {
props: ['store'],
data: () => ({
fixedAmount: 31,
offset: 10,
steps: [0.7, 0.8, 0.9, 1]
}),
computed: {
count() {
return this.store.perPage;
}
},
methods: {
random() {
return this.steps[Math.floor(Math.random() * this.steps.length)];
}
},
components: {
ContentLoader
}
}
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="search-wrapper">
<input type="text" class="search" :placeholder="store.searchPlaceholder" v-model.trim="filterText" @input="doFilter">
<select class="filter-perPage" v-model="store.perPage" @change="changePerPage">
<option v-for="(value, title) in this.perPageOptions"
:value="value"
:selected="store.perPage === value">{{ title }}</option>
</select>
</div>
</template>
<script>
import debounce from 'lodash/debounce';
export default {
props: ['store'],
data: () => ({
filterText: '',
searchPlaceholder: 'Filter...',
selected: ''
}),
computed: {
perPageOptions() {
const options = {
'25': 25,
'50': 50,
'100': 100,
'200': 200,
'All': ''
};
if (!options[this.store.perPage]) {
options[this.store.perPage] = this.store.perPage;
}
return options;
}
},
created() {
this.doFilter = debounce(() => {
this.$events.fire('filter-set', this.filterText);
}, 250, { leading: false });
this.changePerPage = () => {
this.$events.fire('filter-perPage', this.store.perPage);
};
},
methods: {
resetFilter() {
this.filterText = '';
this.$events.fire('filter-reset');
}
}
}
</script>
<style scoped>
.search-wrapper {
display: flex;
}
.search-wrapper select {
margin-bottom: 0;
margin-left: 1rem;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div>
<vuetable ref="vuetable"
:css="css.table"
:fields="store.fields || []"
:searchFields="store.searchFields || []"
:sortOrder="store.sortOrder"
:multi-sort="true"
:api-mode="true"
:api-url="store.api"
:per-page="perPage"
:append-params="extraParams"
pagination-path="links.pagination"
:show-sort-icons="true"
@vuetable:pagination-data="onPaginationData"
@vuetable:loading="onVuetableLoading"
@vuetable:load-success="onVueTableLoadSuccess"
/>
<div class="flex-list-pagination">
<vuetable-pagination-info ref="paginationInfo"
:info-template="store.paginationInfo"
:info-no-data-template="store.emptyResult"
:css="css.paginationInfo"
/>
<vuetable-pagination ref="pagination"
:css="css.pagination"
@vuetable-pagination:change-page="onChangePage"
/>
</div>
</div>
</template>
<script>
import Vue from 'vue';
import Vuetable from 'vuetable-2/src/components/Vuetable.vue';
import VuetablePagination from "vuetable-2/src/components/VuetablePagination.vue";
import VuetablePaginationInfo from 'vuetable-2/src/components/VuetablePaginationInfo.vue';
import VuetableCssConfig from "../VuetableCssConfig.js";
import set from 'lodash/set';
import unset from 'lodash/unset';
export default {
props: ['store', 'value'],
components: {Vuetable, VuetablePagination, VuetablePaginationInfo},
data: () => ({
css: VuetableCssConfig,
perPage: 10,
data: [],
extraParams: {}
}),
created() {
this.perPage = this.store.perPage;
this.data = Object.values(this.store.data);
},
mounted() {
this.$refs.vuetable.setData(this.store.data);
this.$events.$on('filter-set', event => this.onFilterSet(event));
this.$events.$on('filter-reset', event => this.onFilterReset());
this.$events.$on('filter-perPage', event => this.onFilterPerPage(event));
},
methods: {
onPaginationData(paginationData) {
this.$refs.pagination.setPaginationData(paginationData);
this.$refs.paginationInfo.setPaginationData(paginationData);
},
onFilterSet (filterText) {
set(this.extraParams, 'filter', filterText);
Vue.nextTick(() => this.$refs.vuetable.refresh());
},
onFilterReset () {
unset(this.extraParams, 'filter');
Vue.nextTick(() => this.$refs.vuetable.refresh());
},
onFilterPerPage (limit) {
// console.log('onFilterPerPage', limit, this.store.data);
this.perPage = limit || this.$refs.paginationInfo.tablePagination.total;
// this.$refs.vuetable.perPage = limit;
Vue.nextTick(() => this.$refs.vuetable.refresh());
},
onChangePage(page) {
this.$refs.vuetable.changePage(page);
},
onVuetableLoading() {
this.$emit('input', true);
},
onVueTableLoadSuccess() {
this.$emit('input', false);
}
}
}
</script>

View File

@@ -0,0 +1,19 @@
import Vue from 'vue';
import VueEvents from 'vue-events';
import App from './App.vue';
Vue.use(VueEvents);
const ID = '#flex-objects-list';
const element = document.querySelector(ID);
if (element) {
const initialStore = element.dataset.initialStore;
new Vue({ // eslint-disable-line no-new
el: ID,
render: h => h(App, {
props: {initialStore}
})
});
}

View File

@@ -0,0 +1,3 @@
import './list';
import './columns';
import './filters';

View File

@@ -0,0 +1,393 @@
/**
* (c) Trilby Media, LLC
* Author Djamil Legato
*
* Based on Mark Matyas's Finderjs
* MIT License
*/
import $ from 'jquery';
import EventEmitter from 'eventemitter3';
export const DEFAULTS = {
labelKey: 'name',
valueKey: 'value', // new
childKey: 'children',
iconKey: 'icon', // new
itemKey: 'item-key', // new
itemTrigger: null,
pathBar: true,
className: {
container: 'fjs-container',
pathBar: 'fjs-path-bar',
col: 'fjs-col',
list: 'fjs-list',
item: 'fjs-item',
active: 'fjs-active',
children: 'fjs-has-children',
url: 'fjs-url',
itemPrepend: 'fjs-item-prepend',
itemContent: 'fjs-item-content',
itemAppend: 'fjs-item-append'
}
};
class Finder {
constructor(container, data, options) {
this.$emitter = new EventEmitter();
this.container = $(container);
this.data = data;
this.config = $.extend(true, {}, DEFAULTS, options);
this.container.off('click.finder keydown.finder');
// dom events
this.container.on('click.finder', this.clickEvent.bind(this));
this.container.on('keydown.finder', this.keydownEvent.bind(this));
// internal events
this.$emitter.on('item-selected', this.itemSelected.bind(this));
this.$emitter.on('create-column', this.addColumn.bind(this));
this.$emitter.on('navigate', this.navigate.bind(this));
this.$emitter.on('go-to', this.goTo.bind(this, this.data));
this.container.addClass(this.config.className.container).attr('tabindex', 0);
this.createColumn(this.data);
if (this.config.pathBar) {
this.pathBar = this.createPathBar();
this.pathBar.on('click.finder', '[data-breadcrumb-node]', (event) => {
event.preventDefault();
const location = $(event.currentTarget).data('breadcrumbNode');
this.goTo(this.data, location);
});
}
// '' is <Root>
if (this.config.defaultPath || this.config.defaultPath === '') {
this.goTo(this.data, this.config.defaultPath);
}
}
reload(data = this.data) {
this.createColumn(data);
// '' is <Root>
if (this.config.defaultPath || this.config.defaultPath === '') {
this.goTo(data, this.config.defaultPath);
}
}
createColumn(data, parent) {
const callback = (data) => this.createColumn(data, parent);
if (typeof data === 'function') {
data.call(this, parent, callback);
} else if (Array.isArray(data) || typeof data === 'object') {
if (typeof data === 'object') {
data = Array.from(data);
}
const list = this.config.createList || this.createList;
const div = $('<div />');
div.append(list.call(this, data)).addClass(this.config.className.col);
this.$emitter.emit('create-column', div);
return div;
} else {
throw new Error('Unknown data type');
}
}
createPathBar() {
this.container.siblings(`.${this.config.className.pathBar}`).remove();
const pathBar = $(`<div class="${this.config.className.pathBar}" />`);
pathBar.insertAfter(this.container);
return pathBar;
}
clickEvent(event) {
const target = $(event.target);
const column = target.closest(`.${this.config.className.col}`);
const item = target.closest(`.${this.config.className.item}`);
const prevent = target.is('[data-flexpages-prevent]') ? target : target.closest('[data-flexpages-prevent]');
if (prevent.data('flexpagesPrevent') === undefined) {
return true;
}
if (this.config.itemTrigger) {
if (target.is(this.config.itemTrigger) || target.closest(this.config.itemTrigger).length) {
event.stopPropagation();
event.preventDefault();
this.$emitter.emit('item-selected', {column, item});
}
return true;
}
event.stopPropagation();
event.preventDefault();
if (item.length) {
this.$emitter.emit('item-selected', { column, item });
}
}
keydownEvent(event) {
const codes = { 37: 'left', 38: 'up', 39: 'right', 40: 'down', 13: 'enter' };
if (event.keyCode in codes) {
event.stopPropagation();
event.preventDefault();
this.$emitter.emit('navigate', {
direction: codes[event.keyCode]
});
}
}
itemSelected(value) {
const element = value.item;
if (!element.length) { return false; }
const item = element[0]._item;
const column = value.column;
const data = item[this.config.childKey] || this.data; // TODO: this.data for constant refresh
const active = $(column).find(`.${this.config.className.active}`);
if (active.length) {
active.removeClass(this.config.className.active);
}
element.addClass(this.config.className.active);
column.nextAll().remove(); // ?!?!?
this.container[0].focus();
window.scrollTo(window.pageXOffset, window.pageYOffset);
this.updatePathBar();
let newColumn;
if (data) {
newColumn = this.createColumn(data, item);
this.$emitter.emit('interior-selected', item);
} else {
this.$emitter.emit('leaf-selected', item);
}
return newColumn;
}
addColumn(column) {
this.container.append(column);
this.$emitter.emit('column-created', column);
}
navigate(value) {
const active = this.findLastActive();
const direction = value.direction;
let column;
let item;
let target;
if (active) {
item = active.item;
column = active.column;
if (direction === 'up' && item.prev().length) {
target = item.prev();
} else if (direction === 'down' && item.next().length) {
target = item.next();
} else if (direction === 'right' && column.next().length) {
column = column.next();
target = column.find(`.${this.config.className.item}`).first();
} else if (direction === 'left' && column.prev().length) {
column = column.prev();
target = column.find(`.${this.config.className.active}`).first() || column.find(`.${this.config.className.item}`);
}
} else {
column = this.container.find(`.${this.config.className.col}`).first();
target = column.find(`.${this.config.className.item}`).first();
}
if (active && direction === 'enter') {
const href = active.item.find('a').prop('href');
if (href) {
window.location = href;
}
}
if (target) {
this.$emitter.emit('item-selected', {
column,
item: target
});
if (!this.isInView(target, column, true)) {
this.scrollToView(target[0], column[0]);
}
}
}
goTo(data, path) {
path = Array.isArray(path) ? path : path.split('/').map(bit => bit.trim()).filter(Boolean);
if (path.length) {
this.container.children().remove();
}
if (typeof data === 'function') {
data.call(this, null, (data) => this.selectPath(path, data));
} else {
this.selectPath(path, data);
}
}
selectPath(path, data, column) {
column = column || (path.length ? this.createColumn(data) : this.container.find(`> .${this.config.className.col}`));
const current = path[0] || '';
const children = data.find((item) => item[this.config.itemKey] === current);
const item = column.find(`[data-fjs-item="${current}"]`).first();
const newColumn = this.itemSelected({
column,
item
});
if (!this.isInView(item, column, true)) {
this.scrollToView(item[0], column[0]);
}
path.shift();
if (path.length && children) {
this.selectPath(path, children[this.config.childKey], newColumn);
}
}
findLastActive() {
const active = this.container.find(`.${this.config.className.active}`);
if (!active.length) {
return null;
}
const item = active.last();
const column = item.closest(`.${this.config.className.col}`);
return { item, column };
}
createList(data) {
const list = $('<ul />');
const createItem = this.config.createItem || this.createItem;
const items = data.map((item) => createItem.call(this, item));
const fragments = items.reduce((fragment, current) => {
fragment.appendChild(current[0] || current);
return fragment;
}, document.createDocumentFragment());
list.append(fragments).addClass(this.config.className.list);
return list;
}
createItem(item) {
const listItem = $('<li />');
const listItemClasses = [this.config.className.item];
const link = $(`<a href="${item.href || ''}" />`);
const createItemContent = this.config.createItemContent || this.createItemContent;
const fragment = createItemContent.call(this, item);
link.append(fragment)
.attr('href', '')
.attr('tabindex', -1);
if (item.url) {
link.attr('href', item.url);
listItemClasses.push(item.className);
}
if (item[this.config.childKey]) {
listItemClasses.push(this.config.className[this.config.childKey]);
}
listItem.addClass(listItemClasses.join(' '));
listItem.append(link)
.attr('data-fjs-item', item[this.config.itemKey]);
listItem[0]._item = item;
return listItem;
}
updatePathBar() {
if (!this.config.pathBar) { return false; }
const activeItems = this.container.find(`.${this.config.className.active}`);
let itemKeys = '';
this.pathBar.empty();
activeItems.each((index, activeItem) => {
const item = activeItem._item;
const isLast = (index + 1) === activeItems.length;
itemKeys += `/${item[this.config.itemKey]}`;
this.pathBar.append(`
<span class="breadcrumb-node ${item.icon}" ${item.type === 'dir' || item.child_count > 0 ? `data-breadcrumb-node="${itemKeys}"` : ''}>
<i class="${item.icon}"></i>
<span class="breadcrumb-node-name">${$('<div />').html(item[this.config.labelKey]).html()}</span>
${!isLast ? '<i class="fa fa-fw fa-chevron-right"></i>' : ''}
</span>
`);
});
}
getIcon(type) {
switch (type) {
case 'root':
return 'fa-sitemap';
case 'file':
return 'fa-file-o';
case 'dir':
default:
return 'fa-folder';
}
}
isInView(element, container, partial) {
if (!element.length || !container.length) {
return true;
}
const containerHeight = container.height();
const elementTop = $(element).offset().top - container.offset().top;
const elementBottom = elementTop + $(element).height();
const isTotal = (elementTop >= 0 && elementBottom <= containerHeight);
const isPartial = ((elementTop < 0 && elementBottom > 0) || (elementTop > 0 && elementTop <= container.height())) && partial;
return isTotal || isPartial;
}
scrollToView(element, container) {
const top = parseInt(container.getBoundingClientRect().top, 10);
const bot = parseInt(container.getBoundingClientRect().bottom, 10);
const now_top = parseInt(element.getBoundingClientRect().top, 10);
const now_bot = parseInt(element.getBoundingClientRect().bottom, 10);
let scroll_by = 0;
if (now_top < top) {
scroll_by = -(top - now_top);
} else if (now_bot > bot) {
scroll_by = now_bot - bot;
}
if (scroll_by !== 0) {
container.scrollTop += scroll_by;
}
}
}
export default Finder;

View File

@@ -0,0 +1,34 @@
export default () => {
const inputs = document.querySelectorAll('#pages-filters input[name]');
const filters = {};
const trackMulti = [];
inputs.forEach((filter) => {
if (filter.type === 'checkbox') {
if (filter.indeterminate || filter.checked) {
if (filter.name.match(/\[]$/)) {
const name = filter.name.replace(/\[]$/, '');
if (!filters[name]) {
filters[name] = [];
}
if (!trackMulti.includes(name)) {
trackMulti.push(name);
}
filters[name].push(filter.value);
} else {
filters[filter.name] = filter.value;
}
}
} else if (filter.value) {
filters[filter.name] = filter.value;
}
});
trackMulti.forEach((multi) => {
filters[multi] = filters[multi].join(',');
});
return filters;
};

View File

@@ -0,0 +1,44 @@
document.addEventListener('click', (event) => {
const wrapper = event.target.closest('.checkboxes.indeterminate');
if (wrapper) {
event.preventDefault();
const checkbox = wrapper.querySelector('input[type="checkbox"]:not([disabled])');
const checkStatus = wrapper.dataset._checkStatus;
wrapper.classList.remove('status-checked', 'status-unchecked', 'status-indeterminate');
switch (checkStatus) {
// checked, going indeterminate
case '1':
wrapper.dataset._checkStatus = '2';
checkbox.indeterminate = true;
checkbox.checked = false;
checkbox.value = 0;
wrapper.classList.add('status-indeterminate');
break;
// indeterminate, going unchecked
case '2':
wrapper.dataset._checkStatus = '0';
checkbox.indeterminate = false;
checkbox.checked = false;
checkbox.value = '';
wrapper.classList.add('status-unchecked');
break;
// unchecked, going checked
case '0':
default:
wrapper.dataset._checkStatus = '1';
checkbox.indeterminate = false;
checkbox.checked = true;
checkbox.value = 1;
wrapper.classList.add('status-checked');
break;
}
const input = new CustomEvent('input', { detail: { target: checkbox }});
document.dispatchEvent(input);
}
});
(document.querySelectorAll('input[type="checkbox"][indeterminate="true"]') || []).forEach((input) => { input.indeterminate = true; });

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -0,0 +1,55 @@
name: Flex Objects
slug: flex-objects
type: plugin
version: 1.3.1
description: Flex Objects plugin allows you to manage Flex Objects in Grav Admin.
icon: list-alt
author:
name: Trilby Media
email: hello@trilby.media
homepage: https://github.com/trilbymedia/grav-plugin-flex-objects
keywords: grav, plugin, crud, directory
bugs: https://github.com/trilbymedia/grav-plugin-flex-objects/issues
docs: https://github.com/trilbymedia/grav-plugin-flex-objects/blob/develop/README.md
license: MIT
dependencies:
- { name: grav, version: '>=1.7.32' }
- { name: form, version: '>=6.0.0' }
form:
validation: loose
fields:
enabled:
type: toggle
label: PLUGIN_ADMIN.PLUGIN_STATUS
highlight: 1
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
built_in_css:
type: toggle
label: PLUGIN_FLEX_OBJECTS.USE_BUILT_IN_CSS
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
extra_admin_twig_path:
type: text
label: PLUGIN_FLEX_OBJECTS.EXTRA_ADMIN_TWIG_PATH
validate:
required: true
directories:
type: flex-objects
label: PLUGIN_FLEX_OBJECTS.DIRECTORIES
array: true
ignore_empty: true
validate:
type: array

View File

@@ -0,0 +1,16 @@
type: flex-objects
form:
fields:
# Don't mess with this stuff below unless you know what you are doing
tools_section:
type: section
field_classes: overlay bottom
fields:
_post_entries_save:
label: PLUGIN_FLEX_OBJECTS.AFTER_SAVE
type: save-redirect
default: create-new

View File

@@ -0,0 +1,158 @@
title: Contacts
description: Simple contact directory with tags.
type: flex-objects
# Flex Configuration
config:
# Administration Configuration
admin:
# Admin router (optional)
router:
path: '/contacts'
# Admin menu (optional)
menu:
list:
route: '/contacts'
title: Contacts
icon: fa-address-card-o
# Authorization to collection admin
authorize: ['admin.contacts.list', 'admin.super']
# Priority -10 .. 10 (highest goes up)
priority: 2
# Admin template type / folder
template: default
# Permissions
permissions:
# Primary permissions
admin.contacts:
type: crudpl
label: Contacts
# List view
list:
title: name
fields:
published:
field:
type: toggle
label: Publ
width: 8
last_name:
link: edit
first_name:
link: edit
email:
website:
tags:
# Edit View
edit:
title:
template: '{{ object.last_name ?? ''Last'' }}, {{ object.first_name ?? ''First Name'' }}'
# Preview View
preview:
enabled: false
route:
#template: '/plugins/flex-objects/directory:contacts'
# Data Export
export:
enabled: true
method: 'jsonSerialize'
formatter:
class: 'Grav\Framework\File\Formatter\YamlFormatter'
filename: 'contacts'
# Site Configuration
site:
templates:
collection:
# Lookup for the template layout files for collections of objects
paths:
- 'flex/{TYPE}/collection/{LAYOUT}{EXT}'
object:
# Lookup for the template layout files for objects
paths:
- 'flex/{TYPE}/object/{LAYOUT}{EXT}'
defaults:
# Default template {TYPE}; overridden by filename of this blueprint if template folder exists
type: contacts
# Default template {LAYOUT}; can be overridden in render calls (usually Twig in templates)
layout: default
# Data Configuration
data:
# Object class to be used, allowing custom methods for the object
object: 'Grav\Common\Flex\Types\Generic\GenericObject'
# Collection class to be used, allowing custom methods for the collections
collection: 'Grav\Common\Flex\Types\Generic\GenericCollection'
# Index class to be used, works as a quick database-like lookup index
index: 'Grav\Common\Flex\Types\Generic\GenericIndex'
storage:
# Storage class, use single file storage (does not support images and assets)
class: 'Grav\Framework\Flex\Storage\SimpleStorage'
options:
formatter:
# File formatter class, in this case the file is stored in markdown
class: 'Grav\Framework\File\Formatter\JsonFormatter'
# JSON file where all the objects will be stored
folder: user-data://flex-objects/contacts.json
search:
# Search options
options:
contains: 1
# Fields to be searched
fields:
- last_name
- first_name
- email
form:
validation: loose
fields:
published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
required: true
last_name:
type: text
label: Last Name
validate:
required: true
first_name:
type: text
label: First Name
validate:
required: true
email:
type: email
label: Email Address
validate:
required: true
website:
type: url
label: Website URL
tags:
type: selectize
size: large
label: Tags
classes: fancy
validate:
type: commalist

View File

@@ -0,0 +1,22 @@
title: Pages (Admin)
description: Manage your Grav Pages in the new Flex Pages admin. This allows you to use Flex Pages only in Admin plugin, while still using the old pages in the frontend.
type: flex-objects
extends@:
type: pages
context: blueprints://flex
form:
fields:
lang:
type: hidden
value: ''
tabs:
fields:
security:
type: tab
title: PLUGIN_ADMIN.SECURITY
import@:
type: partials/security
context: blueprints://pages

View File

@@ -0,0 +1,7 @@
title: User Accounts (Admin)
description: Manage your User Accounts in the new Flex Accounts admin. This allows you to use Flex Accounts only in Admin plugin, while still using the old users in the frontend.
type: flex-objects
extends@:
type: user-accounts
context: blueprints://flex

View File

@@ -0,0 +1,7 @@
title: User Groups (Admin)
description: Manage your User Groups in the new Flex admin.
type: flex-objects
extends@:
type: user-groups
context: blueprints://flex

View File

@@ -0,0 +1,21 @@
title: Flex Directory
extends@: default
form:
fields:
tabs:
type: tabs
active: 1
fields:
content:
fields:
header.flex.directory:
ordering@: 2
type: select
label: Flex Directory
style: vertical
data-options@: 'Grav\Plugin\FlexObjectsPlugin::directoryOptions'
options:
'': 'Directory List'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Inflector;
use Grav\Common\Language\Language;
use Grav\Common\Session;
use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Controller\Traits\ControllerResponseTrait;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Framework\Flex\FlexForm;
use Grav\Framework\Flex\FlexFormFlash;
use Grav\Framework\Flex\Interfaces\FlexFormInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Psr7\Response;
use Grav\Framework\RequestHandler\Exception\NotFoundException;
use Grav\Framework\RequestHandler\Exception\PageExpiredException;
use Grav\Framework\Route\Route;
use Grav\Plugin\FlexObjects\Flex;
use Grav\Plugin\Form\Forms;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\Session\Message;
use function in_array;
use function is_callable;
/**
* Class AbstractController
* @package Grav\Plugin\FlexObjects\Controllers
*/
abstract class AbstractController implements RequestHandlerInterface
{
use ControllerResponseTrait;
/** @var string */
protected $nonce_action = 'flex-object';
/** @var string */
protected $nonce_name = 'nonce';
/** @var ServerRequestInterface */
protected $request;
/** @var Grav */
protected $grav;
/** @var UserInterface|null */
protected $user;
/** @var string */
protected $type;
/** @var string */
protected $key;
/** @var FlexDirectory */
protected $directory;
/** @var FlexObjectInterface */
protected $object;
/**
* Handle request.
*
* Fires event: flex.[directory].[task|action].[command]
*
* @param ServerRequestInterface $request
* @return Response
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$attributes = $request->getAttributes();
$this->request = $request;
$this->grav = $attributes['grav'] ?? Grav::instance();
$this->type = $attributes['type'] ?? null;
$this->key = $attributes['key'] ?? null;
if ($this->type) {
$this->directory = $this->getFlex()->getDirectory($this->type);
$this->object = $attributes['object'] ?? null;
if (!$this->object && $this->key && $this->directory) {
$this->object = $this->directory->getObject($this->key) ?? $this->directory->createObject([], $this->key ?? '');
if (is_callable([$this->object, 'refresh'])) {
$this->object->refresh();
}
}
}
/** @var Route $route */
$route = $attributes['route'];
$post = $this->getPost();
if ($this->isFormSubmit()) {
$form = $this->getForm();
$this->nonce_name = $attributes['nonce_name'] ?? $form->getNonceName();
$this->nonce_action = $attributes['nonce_action'] ?? $form->getNonceAction();
}
try {
$task = $request->getAttribute('task') ?? $post['task'] ?? $route->getParam('task');
if ($task) {
if (empty($attributes['forwarded'])) {
$this->checkNonce($task);
}
$type = 'task';
$command = $task;
} else {
$type = 'action';
$command = $request->getAttribute('action') ?? $post['action'] ?? $route->getParam('action') ?? 'display';
}
$command = strtolower($command);
$event = new Event(
[
'controller' => $this,
'response' => null
]
);
$this->grav->fireEvent("flex.{$this->type}.{$type}.{$command}", $event);
$response = $event['response'];
if (!$response) {
/** @var Inflector $inflector */
$inflector = $this->grav['inflector'];
$method = $type . $inflector::camelize($command);
if ($method && method_exists($this, $method)) {
$response = $this->{$method}($request);
} else {
throw new NotFoundException($request);
}
}
} catch (\Exception $e) {
$response = $this->createErrorResponse($e);
}
if ($response instanceof Response) {
return $response;
}
return $this->createJsonResponse($response);
}
/**
* @return ServerRequestInterface
*/
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
/**
* @param string|null $name
* @param mixed $default
* @return mixed
*/
public function getPost(string $name = null, $default = null)
{
$body = $this->request->getParsedBody();
if ($name) {
return $body[$name] ?? $default;
}
return $body;
}
/**
* @return bool
*/
public function isFormSubmit(): bool
{
return (bool)$this->getPost('__form-name__');
}
/**
* @param string|null $type
* @return FlexForm
*/
public function getForm(string $type = null): FlexFormInterface
{
$object = $this->getObject();
if (!$object) {
throw new \RuntimeException('Not Found', 404);
}
$formName = $this->getPost('__form-name__');
if ($formName) {
/** @var Forms $forms */
$forms = $this->getGrav()['forms'];
$form = $forms->getActiveForm();
if ($form instanceof FlexForm && $form->getName() === $formName && $form->getObject()->getFlexKey() === $object->getFlexKey()) {
return $form;
}
}
return $object->getForm($type ?? 'edit');
}
/**
* @param FlexObjectInterface $object
* @param string $type
* @return FlexFormFlash
*/
protected function getFormFlash(FlexObjectInterface $object, string $type = '')
{
/** @var Uri $uri */
$uri = $this->grav['uri'];
$url = $uri->url;
$formName = $this->getPost('__form-name__');
if (!$formName) {
$form = $object->getForm($type);
$formName = $form->getName();
$uniqueId = $form->getUniqueId();
} else {
$uniqueId = $this->getPost('__unique_form_id__') ?: $formName ?: sha1($url);
}
/** @var Session $session */
$session = $this->grav['session'];
$config = [
'session_id' => $session->getId(),
'unique_id' => $uniqueId,
'form_name' => $formName,
];
$flash = new FlexFormFlash($config);
if (!$flash->exists()) {
$flash->setUrl($url)->setUser($this->grav['user']);
}
return $flash;
}
/**
* @return Grav
*/
public function getGrav(): Grav
{
return $this->grav;
}
/**
* @return Session
*/
public function getSession(): Session
{
return $this->grav['session'];
}
/**
* @return Flex
*/
public function getFlex(): Flex
{
return $this->grav['flex_objects'];
}
/**
* @return string
*/
public function getDirectoryType(): string
{
return $this->type;
}
/**
* @return string
*/
public function getObjectKey(): string
{
return $this->key;
}
/**
* @return FlexDirectory|null
*/
public function getDirectory(): ?FlexDirectory
{
return $this->directory;
}
/**
* @return FlexObjectInterface|null
*/
public function getObject(): ?FlexObjectInterface
{
return $this->object;
}
/**
* @param string $string
* @param array $args
* @return string
*/
public function translate(string $string, ...$args): string
{
/** @var Language $language */
$language = $this->grav['language'];
array_unshift($args, $string);
return $language->translate($args);
}
/**
* @param string $message
* @param string $type
* @return $this
*/
public function setMessage(string $message, string $type = 'info'): self
{
/** @var Message $messages */
$messages = $this->grav['messages'];
$messages->add($message, $type);
return $this;
}
/**
* @param UserInterface $user
* @return void
*/
public function setUser(UserInterface $user): void
{
$this->user = $user;
}
/**
* @return Config
*/
protected function getConfig(): Config
{
return $this->grav['config'];
}
/**
* @param string $task
* @return void
* @throws PageExpiredException
*/
protected function checkNonce(string $task): void
{
$nonce = null;
if (in_array(strtoupper($this->request->getMethod()), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$nonce = $this->getPost($this->nonce_name);
}
if (!$nonce) {
$nonce = $this->grav['uri']->param($this->nonce_name);
}
if (!$nonce) {
$nonce = $this->grav['uri']->query($this->nonce_name);
}
if (!$nonce || !Utils::verifyNonce($nonce, $this->nonce_action)) {
throw new PageExpiredException($this->request);
}
}
}

View File

@@ -0,0 +1,675 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Controllers;
use Exception;
use Grav\Common\Debugger;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Medium\Medium;
use Grav\Common\Page\Medium\MediumFactory;
use Grav\Common\Utils;
use Grav\Framework\Flex\FlexObject;
use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Media\Interfaces\MediaInterface;
use LogicException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use RocketTheme\Toolbox\Event\Event;
use RuntimeException;
use function is_array;
use function is_string;
/**
* Class MediaController
* @package Grav\Plugin\FlexObjects\Controllers
*/
class MediaController extends AbstractController
{
/**
* @return ResponseInterface
*/
public function taskMediaUpload(): ResponseInterface
{
$this->checkAuthorization('media.create');
$object = $this->getObject();
if (null === $object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'checkUploadedMediaFile')) {
throw new RuntimeException('Not Found', 404);
}
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
// Get field for the uploaded media.
$field = $this->getPost('name', 'undefined');
if ($field === 'undefined') {
$field = null;
}
$request = $this->getRequest();
$files = $request->getUploadedFiles();
if ($field && isset($files['data'])) {
$files = $files['data'];
$parts = explode('.', $field);
$last = array_pop($parts);
foreach ($parts as $name) {
if (!is_array($files[$name])) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
}
$files = $files[$name];
}
$file = $files[$last] ?? null;
} else {
// Legacy call with name being the filename instead of field name.
$file = $files['file'] ?? null;
$field = null;
}
/** @var UploadedFileInterface $file */
if (is_array($file)) {
$file = reset($file);
}
if (!$file instanceof UploadedFileInterface) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
}
$filename = $file->getClientFilename();
$object->checkUploadedMediaFile($file, $filename, $field);
try {
// TODO: This only merges main level data, but is good for ordering (for now).
$data = $flash->getData() ?? [];
$data = array_replace($data, (array)$this->getPost('data'));
$crop = $this->getPost('crop');
if (is_string($crop)) {
$crop = json_decode($crop, true, 512, JSON_THROW_ON_ERROR);
}
$flash->setData($data);
$flash->addUploadedFile($file, $field, $crop);
$flash->save();
} catch (Exception $e) {
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
}
// Include exif metadata into the response if configured to do so
$metadata = [];
$include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
if ($include_metadata) {
$medium = MediumFactory::fromUploadedFile($file);
$media = $object->getMedia();
$media->add($filename, $medium);
$basename = str_replace(['@3x', '@2x'], '', Utils::pathinfo($filename, PATHINFO_BASENAME));
if (isset($media[$basename])) {
$metadata = $media[$basename]->metadata() ?: [];
}
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'metadata' => $metadata
];
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function taskMediaUploadMeta(): ResponseInterface
{
try {
$this->checkAuthorization('media.create');
$object = $this->getObject();
if (null === $object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'getMediaField')) {
throw new RuntimeException('Not Found', 404);
}
$object->refresh();
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
// Get field and data for the uploaded media.
$field = (string)$this->getPost('field');
$media = $object->getMediaField($field);
if (!$media) {
throw new RuntimeException('Media field not found: ' . $field, 404);
}
$data = $this->getPost('data');
if (is_string($data)) {
$data = json_decode($data, true);
}
$filename = Utils::basename($data['name'] ?? '');
// Update field.
$files = $object->getNestedProperty($field, []);
// FIXME: Do we want to save something into the field as well?
$files[$filename] = [];
$object->setNestedProperty($field, $files);
$info = [
'modified' => $data['modified'] ?? null,
'size' => $data['size'] ?? null,
'mime' => $data['mime'] ?? null,
'width' => $data['width'] ?? null,
'height' => $data['height'] ?? null,
'duration' => $data['duration'] ?? null,
'orientation' => $data['orientation'] ?? null,
'meta' => array_filter($data, static function ($val) { return $val !== null; })
];
$info = array_filter($info, static function ($val) { return $val !== null; });
// As the file may not be saved locally, we need to update the index.
$media->updateIndex([$filename => $info]);
$object->save();
$flash->save();
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
'field' => $field,
'filename' => $filename,
'metadata' => $data
];
} catch (\Exception $e) {
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addException($e);
return $this->createJsonErrorResponse($e);
}
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function taskMediaReorder(): ResponseInterface
{
try {
$this->checkAuthorization('media.update');
$object = $this->getObject();
if (null === $object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'getMediaField')) {
throw new RuntimeException('Not Found', 404);
}
$object->refresh();
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
// Get field and data for the uploaded media.
$field = (string)$this->getPost('field');
$media = $object->getMediaField($field);
if (!$media) {
throw new RuntimeException('Media field not found: ' . $field, 404);
}
// Create id => filename map from all files in the media.
$map = [];
foreach ($media as $name => $medium) {
$id = $medium->get('meta.id');
if ($id) {
$map[$id] = $name;
}
}
// Get reorder list and reorder the map.
$data = $this->getPost('data');
if (is_string($data)) {
$data = json_decode($data, true);
}
$data = array_fill_keys($data, null);
$map = array_filter(array_merge($data, $map), static function($val) { return $val !== null; });
// Reorder the files.
$files = $object->getNestedProperty($field, []);
$map = array_fill_keys($map, null);
$files = array_filter(array_merge($map, $files), static function($val) { return $val !== null; });
// Update field.
$object->setNestedProperty($field, $files);
$object->save();
$flash->save();
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FIELD_REORDER_SUCCESSFUL'),
'field' => $field,
'ordering' => array_keys($files)
];
} catch (\Exception $e) {
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addException($e);
$ex = new RuntimeException($this->translate('PLUGIN_ADMIN.FIELD_REORDER_FAILED', $field), $e->getCode(), $e);
return $this->createJsonErrorResponse($ex);
}
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function taskMediaDelete(): ResponseInterface
{
$this->checkAuthorization('media.delete');
/** @var FlexObjectInterface|null $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
$filename = $this->getPost('filename');
// Handle bad filenames.
if (!Utils::checkFilename($filename)) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
}
try {
$field = $this->getPost('name');
$flash = $this->getFormFlash($object);
$flash->removeFile($filename, $field);
$flash->save();
} catch (Exception $e) {
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
];
return $this->createJsonResponse($response);
}
/**
* Used in pagemedia field.
*
* @return ResponseInterface
*/
public function taskMediaCopy(): ResponseInterface
{
$this->checkAuthorization('media.create');
/** @var FlexObjectInterface|null $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'uploadMediaFile')) {
throw new RuntimeException('Not Found', 404);
}
$request = $this->getRequest();
$files = $request->getUploadedFiles();
$file = $files['file'] ?? null;
if (!$file instanceof UploadedFileInterface) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
}
$post = $request->getParsedBody();
$filename = $post['name'] ?? $file->getClientFilename();
// Upload media right away.
$object->uploadMediaFile($file, $filename);
// Include exif metadata into the response if configured to do so
$metadata = [];
$include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
if ($include_metadata) {
$basename = str_replace(['@3x', '@2x'], '', Utils::pathinfo($filename, PATHINFO_BASENAME));
$media = $object->getMedia();
if (isset($media[$basename])) {
$metadata = $media[$basename]->metadata() ?: [];
}
}
if ($object instanceof PageInterface) {
// Backwards compatibility to existing plugins.
// DEPRECATED: page
$this->grav->fireEvent('onAdminAfterAddMedia', new Event(['object' => $object, 'page' => $object]));
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'metadata' => $metadata
];
return $this->createJsonResponse($response);
}
/**
* Used in pagemedia field.
*
* @return ResponseInterface
*/
public function taskMediaRemove(): ResponseInterface
{
$this->checkAuthorization('media.delete');
/** @var FlexObjectInterface|null $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if (!method_exists($object, 'deleteMediaFile')) {
throw new RuntimeException('Not Found', 404);
}
$field = $this->getPost('field');
$filename = $this->getPost('filename');
// Handle bad filenames.
if (!Utils::checkFilename($filename)) {
throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
}
$object->deleteMediaFile($filename, $field);
if ($field) {
$order = $object->getNestedProperty($field);
unset($order[$filename]);
$object->setNestedProperty($field, $order);
$object->save();
}
if ($object instanceof PageInterface) {
// Backwards compatibility to existing plugins.
// DEPRECATED: page
$this->grav->fireEvent('onAdminAfterDelMedia', new Event(['object' => $object, 'page' => $object, 'media' => $object->getMedia(), 'filename' => $filename]));
}
$response = [
'code' => 200,
'status' => 'success',
'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
];
return $this->createJsonResponse($response);
}
/**
* @return ResponseInterface
*/
public function actionMediaList(): ResponseInterface
{
$this->checkAuthorization('media.list');
/** @var MediaInterface|FlexObjectInterface $object */
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
$media = $object->getMedia();
$media_list = [];
/**
* @var string $name
* @var Medium $medium
*/
foreach ($media->all() as $name => $medium) {
$media_list[$name] = [
'url' => $medium->display($medium->get('extension') === 'svg' ? 'source' : 'thumbnail')->cropZoom(400, 300)->url(),
'size' => $medium->get('size'),
'metadata' => $medium->metadata() ?: [],
'original' => $medium->higherQualityAlternative()->get('filename')
];
}
$response = [
'code' => 200,
'status' => 'success',
'results' => $media_list
];
return $this->createJsonResponse($response);
}
/**
* Used by the filepicker field to get a list of files in a folder.
*
* @return ResponseInterface
*/
protected function actionMediaPicker(): ResponseInterface
{
$this->checkAuthorization('media.list');
/** @var FlexObject $object */
$object = $this->getObject();
if (!$object || !\is_callable([$object, 'getFieldSettings'])) {
throw new RuntimeException('Not Found', 404);
}
// Get updated object from Form Flash.
$flash = $this->getFormFlash($object);
if ($flash->exists()) {
$object = $flash->getObject() ?? $object;
$object->update([], $flash->getFilesByFields());
}
$name = $this->getPost('name');
$settings = $name ? $object->getFieldSettings($name) : null;
if (empty($settings['media_picker_field'])) {
throw new RuntimeException('Not Found', 404);
}
$media = $object->getMediaField($name);
$available_files = [];
$metadata = [];
$thumbs = [];
/**
* @var string $name
* @var Medium $medium
*/
foreach ($media->all() as $name => $medium) {
$available_files[] = $name;
if (isset($settings['include_metadata'])) {
$img_metadata = $medium->metadata();
if ($img_metadata) {
$metadata[$name] = $img_metadata;
}
}
}
// Peak in the flashObject for optimistic filepicker updates
$pending_files = [];
$sessionField = base64_encode($this->grav['uri']->url());
$flash = $this->getSession()->getFlashObject('files-upload');
$folder = $media->getPath() ?: null;
if ($flash && isset($flash[$sessionField])) {
foreach ($flash[$sessionField] as $field => $data) {
foreach ($data as $file) {
$test = \dirname($file['path']);
if ($test === $folder) {
$pending_files[] = $file['name'];
}
}
}
}
$this->getSession()->setFlashObject('files-upload', $flash);
// Handle Accepted file types
// Accept can only be file extensions (.pdf|.jpg)
if (isset($settings['accept'])) {
$available_files = array_filter($available_files, function ($file) use ($settings) {
return $this->filterAcceptedFiles($file, $settings);
});
$pending_files = array_filter($pending_files, function ($file) use ($settings) {
return $this->filterAcceptedFiles($file, $settings);
});
}
if (isset($settings['deny'])) {
$available_files = array_filter($available_files, function ($file) use ($settings) {
return $this->filterDeniedFiles($file, $settings);
});
$pending_files = array_filter($pending_files, function ($file) use ($settings) {
return $this->filterDeniedFiles($file, $settings);
});
}
// Generate thumbs if needed
if (isset($settings['preview_images']) && $settings['preview_images'] === true) {
foreach ($available_files as $filename) {
$thumbs[$filename] = $media[$filename]->zoomCrop(100,100)->url();
}
}
$response = [
'code' => 200,
'status' => 'success',
'files' => array_values($available_files),
'pending' => array_values($pending_files),
'folder' => $folder,
'metadata' => $metadata,
'thumbs' => $thumbs
];
return $this->createJsonResponse($response);
}
/**
* @param string $file
* @param array $settings
* @return false|int
*/
protected function filterAcceptedFiles(string $file, array $settings)
{
$valid = false;
foreach ((array)$settings['accept'] as $type) {
$find = str_replace('*', '.*', $type);
$valid |= preg_match('#' . $find . '$#i', $file);
}
return $valid;
}
/**
* @param string $file
* @param array $settings
* @return false|int
*/
protected function filterDeniedFiles(string $file, array $settings)
{
$valid = true;
foreach ((array)$settings['deny'] as $type) {
$find = str_replace('*', '.*', $type);
$valid = !preg_match('#' . $find . '$#i', $file);
}
return $valid;
}
/**
* @param string $action
* @return void
* @throws LogicException
* @throws RuntimeException
*/
protected function checkAuthorization(string $action): void
{
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
// If object does not have ACL support ignore ACL checks.
if (!$object instanceof FlexAuthorizeInterface) {
return;
}
switch ($action) {
case 'media.list':
$action = 'read';
break;
case 'media.create':
case 'media.update':
case 'media.delete':
$action = $object->exists() ? 'update' : 'create';
break;
default:
throw new LogicException(sprintf('Unsupported authorize action %s', $action), 500);
}
if (!$object->isAuthorized($action, null, $this->user)) {
throw new RuntimeException('Forbidden', 403);
}
}
}

View File

@@ -0,0 +1,543 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Controllers;
use Grav\Common\Grav;
use Grav\Framework\Flex\FlexForm;
use Grav\Framework\Flex\FlexObject;
use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
use Grav\Framework\Route\Route;
use Grav\Plugin\FlexObjects\Events\FlexTaskEvent;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use RuntimeException;
/**
* Object controller is for the frontend.
*
* Currently following tasks are supported:
*
* - save (create or update)
* - create
* - update
* - delete
* - reset
* - preview
*/
class ObjectController extends AbstractController
{
/**
* Save object.
*
* Forwards call to either create or update task.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskSave(ServerRequestInterface $request): ResponseInterface
{
$form = $this->getForm();
$object = $form->getObject();
return $object->exists() ? $this->taskUpdate($request) : $this->taskCreate($request);
}
/**
* Create object.
*
* Task fails if object exists.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskCreate(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('create');
$form = $this->getForm();
$callable = function (array $data, array $files, FlexObject $object) {
if (method_exists($object, 'storeOriginal')) {
$object->storeOriginal();
}
$object->update($data, $files);
if (\is_callable([$object, 'check'])) {
$object->check($this->user);
}
$event = new FlexTaskEvent($this, $object, 'create');
$this->grav->dispatchEvent($event);
$object->save();
};
$form->setSubmitMethod($callable);
$form->handleRequest($request);
if (!$form->isValid()) {
$error = $form->getError();
if ($error) {
$this->setMessage($error, 'error');
}
$errors = $form->getErrors();
foreach ($errors as $field) {
foreach ($field as $error) {
$this->setMessage($error, 'error');
}
}
$data = $form->getData();
if (null !== $data) {
$object = $form->getObject();
$flash = $form->getFlash();
$flash->setObject($object);
$flash->setData($data->toArray());
$flash->save();
}
return $this->createDisplayResponse();
}
// FIXME: make it conditional
$grav = $this->grav;
$grav->fireEvent('gitsync');
$this->object = $form->getObject();
$event = new Event(
[
'task' => 'create',
'controller' => $this,
'object' => $this->object,
'response' => null,
'message' => null,
]
);
$this->grav->fireEvent("flex.{$this->type}.task.create.after", $event);
$this->setMessage($event['message'] ?? $this->translate('PLUGIN_FLEX_OBJECTS.STATE.CREATED_SUCCESSFULLY'), 'info');
if ($event['response']) {
return $event['response'];
}
$redirect = $request->getAttribute('redirect', (string)$request->getUri());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Update object.
*
* Task fails if object does not exist.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskUpdate(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('update');
$form = $this->getForm();
$callable = function (array $data, array $files, FlexObject $object) {
if (method_exists($object, 'storeOriginal')) {
$object->storeOriginal();
}
$object->update($data, $files);
if (\is_callable([$object, 'check'])) {
$object->check($this->user);
}
$event = new FlexTaskEvent($this, $object, 'update');
$this->grav->dispatchEvent($event);
$object->save();
};
$form->setSubmitMethod($callable);
$form->handleRequest($request);
if (!$form->isValid()) {
$error = $form->getError();
if ($error) {
$this->setMessage($error, 'error');
}
$errors = $form->getErrors();
foreach ($errors as $field) {
foreach ($field as $error) {
$this->setMessage($error, 'error');
}
}
$data = $form->getData();
if (null !== $data) {
$object = $form->getObject();
$flash = $form->getFlash();
$flash->setObject($object);
$flash->setData($data->toArray());
$flash->save();
}
return $this->createDisplayResponse();
}
// FIXME: make it conditional
$grav = $this->grav;
$grav->fireEvent('gitsync');
$this->object = $form->getObject();
$event = new Event(
[
'task' => 'update',
'controller' => $this,
'object' => $this->object,
'response' => null,
'message' => null,
]
);
$this->grav->fireEvent("flex.{$this->type}.task.update.after", $event);
$this->setMessage($event['message'] ?? $this->translate('PLUGIN_FLEX_OBJECTS.STATE.UPDATED_SUCCESSFULLY'), 'info');
if ($event['response']) {
return $event['response'];
}
$redirect = $request->getAttribute('redirect', (string)$request->getUri()->getPath());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Delete object.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskDelete(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('delete');
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
$event = new FlexTaskEvent($this, $object, 'delete');
$this->grav->dispatchEvent($event);
$object->delete();
// FIXME: make it conditional
$grav = $this->grav;
$grav->fireEvent('gitsync');
$event = new Event(
[
'task' => 'delete',
'controller' => $this,
'object' => $object,
'response' => null,
'message' => null,
]
);
$this->grav->fireEvent("flex.{$this->type}.task.delete.after", $event);
$this->setMessage($this->translate($event['message'] ?? 'PLUGIN_FLEX_OBJECTS.STATE.DELETED_SUCCESSFULLY'), 'info');
if ($event['response']) {
return $event['response'];
}
$redirect = $request->getAttribute('redirect', (string)$request->getUri()->getPath());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Reset form to original values.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskReset(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('save');
$flash = $this->getForm()->getFlash();
$flash->delete();
$redirect = $request->getAttribute('redirect', (string)$request->getUri()->getPath());
return $this->createRedirectResponse($redirect, 303);
}
/**
* Preview object.
*
* Takes a form input and converts it to visible presentation of the object.
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskPreview(ServerRequestInterface $request): ResponseInterface
{
$this->checkAuthorization('save');
/** @var FlexForm $form */
$form = $this->getForm('edit');
$form->setRequest($request);
if (!$form->validate()) {
$error = $form->getError();
if ($error) {
$this->setMessage($error, 'error');
}
$errors = $form->getErrors();
foreach ($errors as $field) {
foreach ($field as $error) {
$this->setMessage($error, 'error');
}
}
return $this->createRedirectResponse((string)$request->getUri(), 303);
}
$this->object = $form->updateObject();
return $this->actionDisplayPreview();
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaList(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('action', 'media.list');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaUpload(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.upload');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaUploadMeta(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.upload.meta');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaReorder(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.reorder');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskMediaDelete(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('task', 'media.delete');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function taskGetFilesInFolder(ServerRequestInterface $request): ResponseInterface
{
$directory = $this->getDirectory();
if (!$directory) {
throw new RuntimeException('Not Found', 404);
}
return $this->forwardMediaTask('action', 'media.picker');
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
* @deprecated Do not use
*/
public function taskFilesUpload(ServerRequestInterface $request): ResponseInterface
{
/** @var Route $route */
$route = $this->grav['route'];
if ($route->getParam('task') === 'media.upload') {
return $this->taskMediaUpload($request);
}
throw new RuntimeException('Task filesUpload should not be called, please update form plugin!', 400);
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface
* @deprecated Do not use
*/
public function taskRemoveMedia(ServerRequestInterface $request): ResponseInterface
{
/** @var Route $route */
$route = $this->grav['route'];
if ($route->getParam('task') === 'media.delete') {
return $this->taskMediaDelete($request);
}
throw new RuntimeException('Task removeMedia should not be called, please update form plugin!', 400);
}
/**
* Display object preview.
*
* @return ResponseInterface
*/
public function actionDisplayPreview(): ResponseInterface
{
$this->checkAuthorization('save');
$this->checkAuthorization('read');
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('No object found!', 404);
}
$grav = Grav::instance();
$grav['twig']->init();
$grav['theme'];
$content = [
'code' => 200,
'id' => $object->getKey(),
'exists' => $object->exists(),
'html' => (string)$object->render('preview', ['nocache' => []])
];
$accept = $this->getAccept(['application/json', 'text/html']);
if ($accept === 'text/html') {
return $this->createHtmlResponse($content['html']);
}
if ($accept === 'application/json') {
return $this->createJsonResponse($content);
}
throw new RuntimeException('Not found', 404);
}
/**
* @param string $action
* @param string|null $scope
* @return void
* @throws RuntimeException
*/
public function checkAuthorization(string $action, string $scope = null): void
{
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if ($object instanceof FlexAuthorizeInterface) {
if (!$object->isAuthorized($action, $scope, $this->user)) {
throw new RuntimeException('Forbidden', 403);
}
}
}
/**
* @param string[] $actions
* @return void
* @throws RuntimeException
*/
public function checkAuthorizations(array $actions): void
{
$object = $this->getObject();
if (!$object) {
throw new RuntimeException('Not Found', 404);
}
if ($object instanceof FlexAuthorizeInterface) {
$test = false;
foreach ($actions as $action) {
$test |= $object->isAuthorized($action, null, $this->user);
}
if (!$test) {
throw new RuntimeException('Forbidden', 403);
}
}
}
/**
* @param string $type
* @param string $name
* @return ResponseInterface
*/
protected function forwardMediaTask(string $type, string $name): ResponseInterface
{
/** @var Route $route */
$route = $this->grav['route']->withGravParam('task', null)->withGravParam($type, $name);
$object = $this->getObject();
/** @var ServerRequest $request */
$request = $this->grav['request'];
$request = $request
->withAttribute($type, $name)
->withAttribute('type', $this->type)
->withAttribute('key', $this->key)
->withAttribute('storage_key', $object && $object->exists() ? $object->getStorageKey() : null)
->withAttribute('route', $route)
->withAttribute('forwarded', true)
->withAttribute('object', $object);
$controller = new MediaController();
if ($this->user) {
$controller->setUser($this->user);
}
return $controller->handle($request);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Grav\Plugin\FlexObjects\Events;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexDirectoryInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Object\Interfaces\ObjectInterface;
use Grav\Plugin\FlexObjects\Controllers\AbstractController;
/**
* @template T as FlexObjectInterface
* @template C as FlexCollectionInterface
*/
class FlexTaskEvent
{
/** @var string */
public $task;
/** @var string */
public $type;
/** @var string */
public $key;
/** @var ObjectInterface */
private $object;
/** @var AbstractController */
private $controller;
/**
* @param AbstractController $controller
* @param string $task
*/
public function __construct(AbstractController $controller, ObjectInterface $object, string $task)
{
$this->task = $task;
$this->type = $controller->getDirectoryType();
$this->key = $controller->getObjectKey();
$this->object = $object;
$this->controller = $controller;
}
/**
* @return AbstractController
*/
public function getController(): AbstractController
{
return $this->controller;
}
/**
* @return FlexDirectoryInterface
*/
public function getDirectory(): FlexDirectoryInterface
{
return $this->getController()->getDirectory();
}
/**
* @return FlexObjectInterface
* @phpstan-return T
*/
public function getModifiedObject(): FlexObjectInterface
{
return $this->object;
}
/**
* @return FlexObjectInterface
* @phpstan-return T
*/
public function getOriginalObject(): FlexObjectInterface
{
return $this->controller->getObject();
}
/**
* @return FlexCollectionInterface
* @phpstan-return C
*/
public function getCollection(): FlexCollectionInterface
{
return $this->getController()->getDirectory()->getCollection();
}
}

View File

@@ -0,0 +1,444 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects;
use Grav\Common\Config\Config;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Utils;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Framework\Flex\FlexObject;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexCommonInterface;
use Grav\Framework\Flex\Interfaces\FlexDirectoryInterface;
use Grav\Framework\Flex\Interfaces\FlexInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Plugin\FlexObjects\Admin\AdminController;
use Grav\Plugin\FlexObjects\Table\DataTable;
/**
* Class Flex
* @package Grav\Plugin\FlexObjects
*/
class Flex implements FlexInterface
{
/** @var FlexInterface */
protected $flex;
/** @var array */
protected $adminRoutes;
/** @var array */
protected $adminMenu;
/** @var array */
protected $managed;
/**
* @param bool $newToOld
* @return array
* @internal
*/
public static function getLegacyBlueprintMap(bool $newToOld = true): array
{
$map = [
'blueprints://flex-objects/pages.yaml' => 'blueprints://flex-objects/grav-pages.yaml',
'blueprints://flex-objects/user-accounts.yaml' => 'blueprints://flex-objects/grav-accounts.yaml',
'blueprints://flex-objects/user-groups.yaml' => 'blueprints://flex-objects/grav-user-groups.yaml'
];
return $newToOld ? $map : array_flip($map);
}
/**
* Flex constructor.
* @param FlexInterface $flex
* @param array $types
*/
public function __construct(FlexInterface $flex, array $types)
{
$this->flex = $flex;
$this->managed = [];
$legacy = static::getLegacyBlueprintMap(false);
foreach ($types as $blueprint) {
// Backwards compatibility to v1.0.0-rc.3
$blueprint = $legacy[$blueprint] ?? $blueprint;
$type = Utils::basename((string)$blueprint, '.yaml');
if ($type) {
$this->managed[] = $type;
}
}
}
/**
* @param string $type
* @param string $blueprint
* @param array $config
* @return $this
*/
public function addDirectoryType(string $type, string $blueprint, array $config = [])
{
$this->flex->addDirectoryType($type, $blueprint, $config);
return $this;
}
/**
* @param FlexDirectory $directory
* @return $this
*/
public function addDirectory(FlexDirectory $directory)
{
$this->flex->addDirectory($directory);
return $this;
}
/**
* @param string $type
* @return bool
*/
public function hasDirectory(string $type): bool
{
return $this->flex->hasDirectory($type);
}
/**
* @param string[]|null $types
* @param bool $keepMissing
* @return array<FlexDirectoryInterface|null>
*/
public function getDirectories(array $types = null, bool $keepMissing = false): array
{
return $this->flex->getDirectories($types, $keepMissing);
}
/**
* Get directories which are not hidden in the site.
*
* @return array
*/
public function getDefaultDirectories(): array
{
$list = $this->getDirectories();
foreach ($list as $type => $directory) {
if ($directory->getConfig('site.hidden', false)) {
unset($list[$type]);
}
}
return $list;
}
/**
* @param string $type
* @return FlexDirectory|null
*/
public function getDirectory(string $type): ?FlexDirectory
{
return $this->flex->getDirectory($type);
}
/**
* @param string $type
* @param array|null $keys
* @param string|null $keyField
* @return FlexCollectionInterface|null
*/
public function getCollection(string $type, array $keys = null, string $keyField = null): ?FlexCollectionInterface
{
return $this->flex->getCollection($type, $keys, $keyField);
}
/**
* @param array $keys
* @param array $options In addition to the options in getObjects(), following options can be passed:
* collection_class: Class to be used to create the collection. Defaults to ObjectCollection.
* @return FlexCollectionInterface
* @throws \RuntimeException
*/
public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface
{
return $this->flex->getMixedCollection($keys, $options);
}
/**
* @param array $keys
* @param array $options Following optional options can be passed:
* types: List of allowed types.
* type: Allowed type if types isn't defined, otherwise acts as default_type.
* default_type: Set default type for objects given without type (only used if key_field isn't set).
* keep_missing: Set to true if you want to return missing objects as null.
* key_field: Key field which is used to match the objects.
* @return array
*/
public function getObjects(array $keys, array $options = []): array
{
return $this->flex->getObjects($keys, $options);
}
/**
* @param string $key
* @param string|null $type
* @param string|null $keyField
* @return FlexObjectInterface|null
*/
public function getObject(string $key, string $type = null, string $keyField = null): ?FlexObjectInterface
{
return $this->flex->getObject($key, $type, $keyField);
}
/**
* @return int
*/
public function count(): int
{
return $this->flex->count();
}
public function isManaged(string $type): bool
{
return \in_array($type, $this->managed, true);
}
/**
* @return array
*/
public function getAll(): array
{
$directories = $this->getDirectories($this->managed);
$all = $this->getBlueprints();
/** @var FlexDirectory $directory */
foreach ($all as $type => $directory) {
if (!isset($directories[$type])) {
$directories[$type] = $directory;
}
}
ksort($directories);
return $directories;
}
/**
* @return array
*/
public function getBlueprints(): array
{
$params = [
'pattern' => '|\.yaml|',
'value' => 'Url',
'recursive' => false,
'folders' => false
];
$directories = [];
$all = Folder::all('blueprints://flex-objects', $params);
foreach ($all as $url) {
$type = Utils::basename($url, '.yaml');
$directory = new FlexDirectory($type, $url);
if ($directory->getConfig('hidden') !== true) {
$directories[$type] = $directory;
}
}
// Order blueprints by title.
usort($directories, static function (FlexDirectory $a, FlexDirectory $b) {
return $a->getTitle() <=> $b->getTitle();
});
return $directories;
}
/**
* @param string|FlexDirectory $type
* @param array $options
* @return DataTable
*/
public function getDataTable($type, array $options = []): DataTable
{
$directory = $type instanceof FlexDirectory ? $type : $this->getDirectory($type);
if (!$directory) {
throw new \RuntimeException('Not Found', 404);
}
$collection = $options['collection'] ?? $directory->getCollection();
if (isset($options['filters']) && is_array($options['filters'])) {
$collection = $collection->filterBy($options['filters']);
}
$table = new DataTable($options);
$table->setCollection($collection);
return $table;
}
/**
* @param string|object|null $type
* @param array $params
* @param string $extension
* @return string
*/
public function adminRoute($type = null, array $params = [], string $extension = ''): string
{
if (\is_object($type)) {
$object = $type;
if ($object instanceof FlexCommonInterface || $object instanceof FlexDirectory) {
$type = $type->getFlexType();
} else {
return '';
}
} else {
$object = null;
}
$routes = $this->getAdminRoutes();
$grav = Grav::instance();
/** @var Config $config */
$config = $grav['config'];
if (!Utils::isAdminPlugin()) {
$parts = [
trim($grav['base_url'], '/'),
trim($config->get('plugins.admin.route'), '/')
];
}
if ($type && isset($routes[$type])) {
if (!$routes[$type]) {
// Directory has empty route.
return '';
}
// Directory has it's own menu item.
$parts[] = trim($routes[$type], '/');
} else {
if (empty($routes[''])) {
// Default route has been disabled.
return '';
}
// Use default route.
$parts[] = trim($routes[''], '/');
if ($type) {
$parts[] = $type;
}
}
// Append object key if available.
if ($object instanceof FlexObject) {
if ($object->exists()) {
$parts[] = trim($object->getKey(), '/');
} else {
if ($object->hasKey()) {
$parts[] = trim($object->getKey(), '/');
}
$params = ['' => 'add'] + $params;
}
}
$p = [];
$separator = $config->get('system.param_sep');
foreach ($params as $key => $val) {
$p[] = $key . $separator . $val;
}
$parts = array_filter($parts, static function ($val) { return $val !== ''; });
$route = '/' . implode('/', $parts);
$extension = $extension ? '.' . $extension : '';
return $route . $extension . ($p ? '/' . implode('/', $p) : '');
}
public function getAdminController(): ?AdminController
{
$grav = Grav::instance();
if (!isset($grav['admin'])) {
return null;
}
/** @var PageInterface $page */
$page = $grav['page'];
$header = $page->header();
$callable = $header->controller['controller']['instance'] ?? null;
if (null !== $callable && \is_callable($callable)) {
return $callable();
}
return null;
}
/**
* @return array
*/
public function getAdminRoutes(): array
{
if (null === $this->adminRoutes) {
$routes = [];
/** @var FlexDirectory $directory */
foreach ($this->getDirectories() as $directory) {
$config = $directory->getConfig('admin');
if (!$directory->isEnabled() || !empty($config['disabled'])) {
continue;
}
// Resolve route.
$route = $config['router']['path']
?? $config['menu']['list']['route']
?? "/flex-objects/{$directory->getFlexType()}";
$routes[$directory->getFlexType()] = $route;
}
$this->adminRoutes = $routes;
}
return $this->adminRoutes;
}
/**
* @return array
*/
public function getAdminMenuItems(): array
{
if (null === $this->adminMenu) {
$routes = [];
$count = 0;
$directories = $this->getDirectories();
/** @var FlexDirectory $directory */
foreach ($directories as $directory) {
$config = $directory->getConfig('admin');
if (!$directory->isEnabled() || !empty($config['disabled'])) {
continue;
}
$type = $directory->getFlexType();
$items = $directory->getConfig('admin.menu') ?? [];
if ($items) {
foreach ($items as $view => $item) {
$item += [
'route' => '/' . $type,
'title' => $directory->getTitle(),
'icon' => 'fa fa-file',
'directory' => $type
];
$routes[$type] = $item;
}
} else {
$count++;
}
}
if ($count && !isset($routes[''])) {
$routes[''] = ['route' => '/flex-objects'];
}
$this->adminMenu = $routes;
}
return $this->adminMenu;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Page;
use Grav\Framework\Form\Interfaces\FormFactoryInterface;
use Grav\Framework\Form\Interfaces\FormInterface;
use RocketTheme\Toolbox\Event\Event;
use function is_callable;
use function is_string;
/**
* Class FlexFormFactory
* @package Grav\Plugin\FlexObjects
*/
class FlexFormFactory implements FormFactoryInterface
{
/**
* @param Page $page
* @param string $name
* @param array $form
* @return FormInterface|null
*/
public function createPageForm(Page $page, string $name, array $form): ?FormInterface
{
return $this->createFormForPage($page, $name, $form);
}
/**
* @param PageInterface $page
* @param string $name
* @param array $form
* @return FormInterface|null
*/
public function createFormForPage(PageInterface $page, string $name, array $form): ?FormInterface
{
// Fire event
$grav = Grav::instance();
$grav->fireEvent('onBeforeFlexFormInitialize', new Event(['page' => $page, 'name' => $name, 'form' => &$form]));
$page->addForms([$form], true);
$formFlex = $form['flex'] ?? [];
$type = $formFlex['type'] ?? null;
$key = $formFlex['key'] ?? null;
if (null !== $key && !is_string($key)) {
$key = (string)$key;
}
$layout = $formFlex['layout'] ?? $name;
/** @var Flex $flex */
$flex = Grav::instance()['flex_objects'];
if (is_string($type)) {
$directory = $flex->getDirectory($type);
if (!$directory) {
return null;
}
$create = $form['actions']['create'] ?? true;
$edit = $form['actions']['edit'] ?? true;
$object = $edit && null !== $key ? $directory->getObject($key) : null;
if ($object) {
if (is_callable([$object, 'refresh'])) {
$object->refresh();
}
} elseif ($create) {
$object = $directory->createObject([], $key ?? '');
}
} else {
$object = $flex->getObject($key);
}
return $object ? $object->getForm($layout, ['form' => $form]) : null;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Grav\Plugin\FlexObjects;
use Grav\Framework\Route\Route;
use Grav\Plugin\FlexObjects\Controllers\MediaController;
use Grav\Plugin\FlexObjects\Controllers\ObjectController;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Class FlexRouter
* @package Grav\Plugin\FlexObjects
*/
class FlexRouter implements MiddlewareInterface
{
/**
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$context = $request->getAttributes();
/** @var Route $route */
$route = $context['route'];
$post = $request->getParsedBody();
$task = $post['task'] ?? $route->getParam('task');
if (\in_array($task, ['cropupload', 'filesupload'])) {
$task = 'media.upload';
}
switch ($task) {
case 'media.upload':
case 'media.delete':
case 'media.copy':
case 'media.remove':
case 'media.list':
case 'media.add':
case 'listmedia':
case 'addmedia':
case 'delmedia':
return (new MediaController())->handle($request);
case 'save':
case 'create':
case 'update':
case 'delete':
case 'reset':
case 'preview':
case 'move':
return (new ObjectController())->handle($request);
}
// No handler found.
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\FlexObjects\Table;
use Grav\Common\Debugger;
use Grav\Common\Grav;
use Grav\Framework\Collection\CollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use JsonSerializable;
use Throwable;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use function is_array;
use function is_string;
/**
* Class DataTable
* @package Grav\Plugin\Gitea
*
* https://github.com/ratiw/vuetable-2/wiki/Data-Format-(JSON)
* https://github.com/ratiw/vuetable-2/wiki/Sorting
*/
class DataTable implements JsonSerializable
{
/** @var string */
private $url;
/** @var int */
private $limit;
/** @var int */
private $page;
/** @var array */
private $sort;
/** @var string */
private $search;
/** @var FlexCollectionInterface */
private $collection;
/** @var FlexCollectionInterface */
private $filteredCollection;
/** @var array */
private $columns;
/** @var Environment */
private $twig;
/** @var array */
private $twig_context;
/**
* DataTable constructor.
* @param array $params
*/
public function __construct(array $params)
{
$this->setUrl($params['url'] ?? '');
$this->setLimit((int)($params['limit'] ?? 10));
$this->setPage((int)($params['page'] ?? 1));
$this->setSort($params['sort'] ?? ['id' => 'asc']);
$this->setSearch($params['search'] ?? '');
}
/**
* @param string $url
* @return void
*/
public function setUrl(string $url): void
{
$this->url = $url;
}
/**
* @param int $limit
* @return void
*/
public function setLimit(int $limit): void
{
$this->limit = max(1, $limit);
}
/**
* @param int $page
* @return void
*/
public function setPage(int $page): void
{
$this->page = max(1, $page);
}
/**
* @param string|string[] $sort
* @return void
*/
public function setSort($sort): void
{
if (is_string($sort)) {
$sort = $this->decodeSort($sort);
} elseif (!is_array($sort)) {
$sort = [];
}
$this->sort = $sort;
}
/**
* @param string $search
* @return void
*/
public function setSearch(string $search): void
{
$this->search = $search;
}
/**
* @param CollectionInterface $collection
* @return void
*/
public function setCollection(CollectionInterface $collection): void
{
$this->collection = $collection;
$this->filteredCollection = null;
}
/**
* @return int
*/
public function getLimit(): int
{
return $this->limit;
}
/**
* @return int
*/
public function getPage(): int
{
return $this->page;
}
/**
* @return int
*/
public function getLastPage(): int
{
return 1 + (int)floor(max(0, $this->getTotal()-1) / $this->getLimit());
}
/**
* @return int
*/
public function getTotal(): int
{
$collection = $this->filteredCollection ?? $this->getCollection();
return $collection ? $collection->count() : 0;
}
/**
* @return array
*/
public function getSort(): array
{
return $this->sort;
}
/**
* @return FlexCollectionInterface|null
*/
public function getCollection(): ?FlexCollectionInterface
{
return $this->collection;
}
/**
* @param int $page
* @return string|null
*/
public function getUrl(int $page): ?string
{
if ($page < 1 || $page > $this->getLastPage()) {
return null;
}
return "{$this->url}.json?page={$page}&per_page={$this->getLimit()}&sort={$this->encodeSort()}";
}
/**
* @return array
*/
public function getColumns(): array
{
if (null === $this->columns) {
$collection = $this->getCollection();
if (!$collection) {
return [];
}
$blueprint = $collection->getFlexDirectory()->getBlueprint();
$schema = $blueprint->schema();
$columns = $blueprint->get('config/admin/views/list/fields') ?? $blueprint->get('config/admin/list/fields', []);
$list = [];
foreach ($columns as $key => $options) {
if (!isset($options['field'])) {
$options['field'] = $schema->get($options['alias'] ?? $key);
}
if (!$options['field'] || !empty($options['field']['ignore'])) {
continue;
}
$list[$key] = $options;
}
$this->columns = $list;
}
return $this->columns;
}
/**
* @return array
*/
public function getData(): array
{
$grav = Grav::instance();
/** @var Debugger $debugger */
$debugger = $grav['debugger'];
$debugger->startTimer('datatable', 'Data Table');
$collection = $this->getCollection();
if (!$collection) {
return [];
}
if ($this->search !== '') {
$collection = $collection->search($this->search);
}
$columns = $this->getColumns();
$collection = $collection->sort($this->getSort());
$this->filteredCollection = $collection;
$limit = $this->getLimit();
$page = $this->getPage();
$to = $page * $limit;
$from = $to - $limit + 1;
if ($from < 1 || $from > $this->getTotal()) {
$debugger->stopTimer('datatable');
return [];
}
$array = $collection->slice($from-1, $limit);
$twig = $grav['twig'];
$grav->fireEvent('onTwigSiteVariables');
$this->twig = $twig->twig;
$this->twig_context = $twig->twig_vars;
$list = [];
/** @var FlexObjectInterface $object */
foreach ($array as $object) {
$item = [
'id' => $object->getKey(),
'timestamp' => $object->getTimestamp()
];
foreach ($columns as $name => $column) {
$item[str_replace('.', '_', $name)] = $this->renderColumn($name, $column, $object);
}
$item['_actions_'] = $this->renderActions($object);
$list[] = $item;
}
$debugger->stopTimer('datatable');
return $list;
}
/**
* @return array
*/
public function jsonSerialize(): array
{
$data = $this->getData();
$total = $this->getTotal();
$limit = $this->getLimit();
$page = $this->getPage();
$to = $page * $limit;
$from = $to - $limit + 1;
$empty = empty($data);
return [
'links' => [
'pagination' => [
'total' => $total,
'per_page' => $limit,
'current_page' => $page,
'last_page' => $this->getLastPage(),
'next_page_url' => $this->getUrl($page+1),
'prev_page_url' => $this->getUrl($page-1),
'from' => $empty ? null : $from,
'to' => $empty ? null : min($to, $total),
]
],
'data' => $data
];
}
/**
* @param string $name
* @param array $column
* @param FlexObjectInterface $object
* @return false|string
* @throws Throwable
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
protected function renderColumn(string $name, array $column, FlexObjectInterface $object)
{
$grav = Grav::instance();
$flex = $grav['flex_objects'];
$value = $object->getFormValue($name) ?? $object->getNestedProperty($name, $column['field']['default'] ?? null);
$type = $column['field']['type'] ?? 'text';
$hasLink = $column['link'] ?? null;
$link = null;
$authorized = $object instanceof FlexAuthorizeInterface
? ($object->isAuthorized('read') || $object->isAuthorized('update')) : true;
if ($hasLink && $authorized) {
$route = $grav['route']->withExtension('');
$link = $route->withAddedPath($object->getKey())->withoutParams()->getUri();
}
$template = $this->twig->resolveTemplate(["forms/fields/{$type}/edit_list.html.twig", 'forms/fields/text/edit_list.html.twig']);
return $this->twig->load($template)->render([
'value' => $value,
'link' => $link,
'field' => $column['field'],
'object' => $object,
'flex' => $flex,
'route' => $grav['route']->withExtension('')
] + $this->twig_context);
}
/**
* @param FlexObjectInterface $object
* @return false|string
* @throws Throwable
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
protected function renderActions(FlexObjectInterface $object)
{
$grav = Grav::instance();
$type = $object->getFlexType();
$template = $this->twig->resolveTemplate(["flex-objects/types/{$type}/list/list_actions.html.twig", 'flex-objects/types/default/list/list_actions.html.twig']);
return $this->twig->load($template)->render([
'object' => $object,
'flex' => $grav['flex_objects'],
'route' => $grav['route']->withExtension('')
] + $this->twig_context);
}
/**
* @param string $sort
* @param string $fieldSeparator
* @param string $orderSeparator
* @return array
*/
protected function decodeSort(string $sort, string $fieldSeparator = ',', string $orderSeparator = '|'): array
{
$strings = explode($fieldSeparator, $sort);
$list = [];
foreach ($strings as $string) {
$item = explode($orderSeparator, $string, 2);
$key = array_shift($item);
$order = array_shift($item) === 'desc' ? 'desc' : 'asc';
$list[$key] = $order;
}
return $list;
}
/**
* @param string $fieldSeparator
* @param string $orderSeparator
* @return string
*/
protected function encodeSort(string $fieldSeparator = ',', string $orderSeparator = '|'): string
{
$list = [];
foreach ($this->getSort() as $key => $order) {
$list[] = $key . $orderSeparator . ($order ?: 'asc');
}
return implode($fieldSeparator, $list);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Grav\Plugin\Console;
use Exception;
use Grav\Common\Utils;
use Grav\Common\Yaml;
use Grav\Console\ConsoleCommand;
use Symfony\Component\Console\Input\InputOption;
use function count;
/**
* Class FlushQueueCommand
* @package Grav\Console\Cli\
*/
class FlexConvertDataCommand extends ConsoleCommand
{
/** @var array */
protected $options = [];
/**
* @return void
*/
protected function configure(): void
{
$this
->setName('convert-data')
->setAliases(['convertdata'])
->addOption(
'in',
'i',
InputOption::VALUE_REQUIRED,
'path to file to convert from (valid types: [json|yaml])'
)
->addOption(
'out',
'o',
InputOption::VALUE_REQUIRED,
'format of file to convert to [json|yaml]'
)
->setDescription('Converts data from one format to another')
->setHelp('The <info>clear-queue-failures</info> command clears any queue failures that have accumulated');
}
/**
* @return int
*/
protected function serve(): int
{
$input = $this->getInput();
$io = $this->getIO();
$out_raw = null;
$in = $input->getOption('in');
$in_parts = Utils::pathinfo($in);
$in_extension = $in_parts['extension'];
$out_extension = $input->getOption('out');
$io->title('Flex Convert Data');
if (!file_exists($in)) {
$io->error('cannot find the file: ' . realpath($in));
return 1;
}
if (!$in_extension) {
$io->error($in . ' has no file extension defined');
return 1;
}
if (!$out_extension) {
$io->error($out_extension . ' is not a valid extension');
return 1;
}
$in_raw = file_get_contents($in);
// Get the input data
if ($in_extension === 'yaml' || $in_extension === 'yml') {
$in_data = Yaml::parse($in_raw);
} elseif ($in_extension === 'json' ) {
$in_data = json_decode($in_raw, true, 512, JSON_THROW_ON_ERROR);
} else {
$io->error('input files with extension ' . $in_extension . ', is not supported');
return 1;
}
// Simple progress bar
$progress = $io->createProgressBar(count($in_data));
$progress->setFormat('verbose');
$progress->start();
// add Unique Id if needed
$index = 0;
$out_data = [];
foreach ($in_data as $key => $entry) {
if ($key === $index++) {
$out_data[$this->generateKey()] = $entry;
} else {
$out_data[$key] = $entry;
}
$progress->advance();
}
// render progress
$progress->finish();
$io->newLine(2);
// Convert to output format
if ($out_extension === 'yaml' || $out_extension === 'yml') {
$out_raw = Yaml::dump($out_data);
} elseif ($out_extension === 'json' ) {
$out_raw = json_encode($out_data, JSON_PRETTY_PRINT);
} else {
$io->error('input files with extension ' . $out_extension . ', is not supported');
return 1;
}
// Write the file:
$out_filename = $in_parts['dirname'] . '/' . $in_parts['filename'] . '.' . $out_extension;
file_put_contents($out_filename, $out_raw);
$io->success('successfully converted the file and saved as: ' . $out_filename);
return 0;
}
/**
* @return string|false
* @throws Exception
*/
protected function generateKey()
{
return substr(hash('sha256', random_bytes(32)), 0, 32);
}
}

View File

@@ -0,0 +1,32 @@
{
"name": "getgrav/grav-plugin-flex-objects",
"type": "grav-plugin",
"description": "Flex Objects plugin for Grav CMS",
"keywords": ["flex-objects"],
"homepage": "https://github.com/trilbymedia/grav-plugin-flex-objects",
"license": "MIT",
"authors": [
{
"name": "Team Grav",
"email": "devs@getgrav.org",
"homepage": "http://getgrav.org",
"role": "Developer"
}
],
"require": {
"php": "^7.3.6 || ^8.0",
"ext-json": "*",
"ext-mbstring": "*"
},
"autoload": {
"psr-4": {
"Grav\\Plugin\\FlexObjects\\": "classes/"
},
"classmap": ["flex-objects.php"]
},
"config": {
"platform": {
"php": "7.3.6"
}
}
}

25
user/plugins/flex-objects/composer.lock generated Normal file
View File

@@ -0,0 +1,25 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8cd40668f2f51e9d1c7bceed7be5d27d",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^7.3.6 || ^8.0",
"ext-json": "*",
"ext-mbstring": "*"
},
"platform-dev": [],
"platform-overrides": {
"php": "7.3.6"
},
"plugin-api-version": "2.2.0"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
{
"version": 3,
"file": "admin.css",
"sources": [
"../scss/admin.scss",
"../hdr0",
"../scss/plugin/_admin.scss"
],
"names": [],
"mappings": "AEAA,+BAA+B;AAE/B,AAAA,MAAM,CAAC;EACH,OAAO,EAAE,IAAI,GAUhB;EAXD,AAGI,MAHE,CAGF,UAAU,CAAC;IACP,SAAS,EAAE,IAAI,GAKlB;IATL,AAMQ,MANF,CAGF,UAAU,CAGN,CAAC,CAAC;MACE,MAAM,EAAE,QAAQ,GACnB;;AAKT,AAEI,UAFM,CAEN,WAAW,CAAC;EACR,OAAO,EAAE,IAAI,GAChB;;AAJL,AAMI,UANM,CAMN,eAAe,CAAC;EACZ,MAAM,EAAE,UAAU,GACrB;;AARL,AAUI,UAVM,CAUN,MAAM,CAAC,kBAAkB,CAAC;EACtB,cAAc,EAAE,MAAM;EACtB,OAAO,EAAE,YAAY,GACxB;;AAbL,AAeI,UAfM,CAeN,mBAAmB,AAAA,SAAS,CAAC;EACzB,OAAO,EAAE,IAAI,GAChB;;AAjBL,AAmBI,UAnBM,CAmBN,YAAY,CAAC;EACT,KAAK,EAAE,IAAI,GACd;;AArBL,AAuBI,UAvBM,CAuBN,cAAc,CAAC;EACX,KAAK,EAAE,KAAK,GACf;;AAzBL,AA2BI,UA3BM,CA2BN,KAAK,CAAC;EACF,OAAO,EAAE,KAAK,GACjB;;AA7BL,AA+BI,UA/BM,CA+BN,KAAK,CAAC;EACF,OAAO,EAAE,kBAAkB,GAC9B;;AAjCL,AAmCI,UAnCM,CAmCN,KAAK,CAAC;EACF,OAAO,EAAE,eAAe,GAC3B;;AArCL,AAuCI,UAvCM,CAuCN,EAAE,CAAC;EACC,OAAO,EAAE,SAAS;EAClB,IAAI,EAAE,IAAI,GACb;;AA1CL,AA4CI,UA5CM,CA4CN,EAAE,EA5CN,UAAU,CA4CF,EAAE,CAAC;EACH,OAAO,EAAE,UAAU;EACnB,IAAI,EAAE,IAAI,GAIb;EAlDL,AA+CQ,UA/CE,CA4CN,EAAE,AAGG,MAAM,EA/Cf,UAAU,CA4CF,EAAE,AAGD,MAAM,CAAC;IACJ,OAAO,EAAE,YAAY,GACxB;;AAjDT,AAoDI,UApDM,CAoDN,EAAE,CAAC;EACC,WAAW,EAAE,MAAM;EACnB,QAAQ,EAAE,MAAM;EAChB,aAAa,EAAE,QAAQ,GAC1B;;AAxDL,AA0DI,UA1DM,CA0DN,EAAE,AAAA,WAAW,CAAC;EACV,UAAU,EAAE,KAAK;EACjB,WAAW,EAAE,MAAM,GAMtB;EAlEL,AA6DQ,UA7DE,CA0DN,EAAE,AAAA,WAAW,CAGT,CAAC,CAAC;IACE,KAAK,EAAE,IAAI;IACX,OAAO,EAAE,YAAY;IACrB,YAAY,EAAE,GAAG,GACpB;;AAjET,AAoEI,UApEM,CAoEN,YAAY,CAAC,SAAS,CAAC;EACnB,OAAO,EAAE,IAAI,GAChB;;AAtEL,AAwEI,UAxEM,CAwEN,MAAM,CAAC;EACH,MAAM,EAAE,MAAM;EACd,UAAU,EAAE,MAAM,GAkBrB;EA5FL,AA4EQ,UA5EE,CAwEN,MAAM,CAIF,YAAY,CAAC;IACT,OAAO,EAAE,MAAM,GAClB;EA9ET,AAgFQ,UAhFE,CAwEN,MAAM,CAQF,OAAO,CAAC;IACJ,MAAM,EAAE,CAAC,GACZ;EAlFT,AAoFQ,UApFE,CAwEN,MAAM,CAYF,KAAK,EApFb,UAAU,CAwEN,MAAM,CAYK,MAAM,CAAC;IACV,OAAO,EAAE,YAAY;IACrB,KAAK,EAAE,IAAI,GACd;EAvFT,AAyFQ,UAzFE,CAwEN,MAAM,CAiBF,MAAM,CAAC;IACH,cAAc,EAAE,MAAM,GACzB;;AA3FT,AA8FI,UA9FM,CA8FN,qBAAqB,CAAC;EAClB,MAAM,EAAE,YAAY;EACpB,OAAO,EAAE,UAAU;EACnB,OAAO,EAAE,IAAI;EACb,WAAW,EAAE,MAAM,GAwBtB;EA1HL,AAoGQ,UApGE,CA8FN,qBAAqB,CAMjB,yBAAyB,EApGjC,UAAU,CA8FN,qBAAqB,CAMU,wBAAwB,CAAC;IAChD,IAAI,EAAE,CAAC,GACV;EAtGT,AAwGQ,UAxGE,CA8FN,qBAAqB,CAUjB,wBAAwB,CAAC;IACrB,UAAU,EAAE,KAAK;IACjB,mBAAmB,EAAE,IAAI;IACzB,gBAAgB,EAAE,IAAI;IACtB,kBAAkB,EAAE,IAAI;IACxB,eAAe,EAAE,IAAI,GAYxB;IAzHT,AA+GY,UA/GF,CA8FN,qBAAqB,CAUjB,wBAAwB,CAOpB,OAAO,CAAC;MACJ,aAAa,EAAE,GAAG;MAClB,UAAU,EAAE,WAAW,GAO1B;MAxHb,AAoHoB,UApHV,CA8FN,qBAAqB,CAUjB,wBAAwB,CAOpB,OAAO,AAIF,SAAS,AACL,OAAO,CAAC;QACL,MAAM,EAAE,CAAC,GACZ;;AAOrB,aAAa;AAEb,AACI,cADU,CACV,WAAW,CAAC;EACR,qBAAqB,EAAE,IAAI;EAAE,gBAAgB;EAC7C,mBAAmB,EAAE,IAAI;EAAE,YAAY;EACvC,kBAAkB,EAAE,IAAI;EAAE,oBAAoB;EAC9C,gBAAgB,EAAE,IAAI;EAAE,aAAa;EACrC,eAAe,EAAE,IAAI;EAAE,4BAA4B;EACnD,WAAW,EAAE,IAAI,GAWpB;EAlBL,AAUY,cAVE,CACV,WAAW,AAQN,qBAAqB,CAClB,KAAK,CAAA,AAAA,IAAC,CAAK,UAAU,AAAf,IAAmB,KAAK,AAAA,OAAO,CAAC;IAClC,OAAO,EAAE,OAAO;IAChB,WAAW,EAAE,yBAAyB;IACtC,SAAS,EAAE,MAAM;IACjB,WAAW,EAAE,CAAC;IACd,UAAU,EAAE,MAAM,GACrB;;AAKb,gBAAgB;AAChB,kBAAkB;AAClB,AAAA,UAAU,CAAC,eAAe,CAAC,KAAK,EAAE,UAAU,CAAC,eAAe,CAAC,MAAM,CAAC;EAClE,KAAK,EAAE,OAAO;EACd,MAAM,EAAE,iBAAiB;EACzB,gBAAgB,EAAE,OAAO,GAC1B;;AACD,AAAA,UAAU,CAAC,eAAe,CAAC,KAAK,AAAA,2BAA2B,EAAE,UAAU,CAAC,eAAe,CAAC,MAAM,AAAA,2BAA2B,CAAC;EACxH,KAAK,EAAE,OAAO,GACf;;AACD,AAAA,UAAU,CAAC,eAAe,CAAC,KAAK,AAAA,kBAAkB,EAAE,UAAU,CAAC,eAAe,CAAC,MAAM,AAAA,kBAAkB,CAAC;EACtG,KAAK,EAAE,OAAO,GACf;;AACD,AAAA,UAAU,CAAC,eAAe,CAAC,KAAK,AAAA,iBAAiB,EAAE,UAAU,CAAC,eAAe,CAAC,MAAM,AAAA,iBAAiB,CAAC;EACpG,KAAK,EAAE,OAAO,GACf;;AACD,AAAA,UAAU,CAAC,eAAe,CAAC,KAAK,AAAA,sBAAsB,EAAE,UAAU,CAAC,eAAe,CAAC,MAAM,AAAA,sBAAsB,CAAC;EAC9G,KAAK,EAAE,OAAO,GACf;;AACD,AAAA,UAAU,CAAC,YAAY,CAAC;EACtB,KAAK,EAAE,kBAAkB,GAC1B;;AACD,AAAA,UAAU,CAAC,cAAc,CAAC;EACxB,KAAK,EAAE,kBAAkB,GAC1B;;AACD,AAAA,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;EAClB,UAAU,EAAE,OAAO,GACpB;;AACD,AAAA,UAAU,CAAC,KAAK,CAAC,EAAE,AAAA,UAAW,CAAA,IAAI,EAAE;EAClC,UAAU,EAAE,KAAK,GAClB;;AACD,AAAA,UAAU,CAAC,CAAC,AAAA,UAAU,CAAC;EACrB,KAAK,EAAE,OAAO,GACf;;AACD,AAAA,UAAU,CAAC,CAAC,AAAA,YAAY,CAAC;EACvB,KAAK,EAAE,OAAO,GACf;;AACD,AAAA,UAAU,CAAC,qBAAqB,CAAC;EAC/B,UAAU,EAAE,OAAO;EACnB,UAAU,EAAE,iBAAiB,GAC9B;;AACD,AAAA,UAAU,CAAC,qBAAqB,CAAC,wBAAwB,CAAC,OAAO,CAAC;EAChE,KAAK,EAAE,OAAO,GACf;;AACD,AAAA,UAAU,CAAC,qBAAqB,CAAC,wBAAwB,CAAC,OAAO,AAAA,OAAO,CAAC;EACvE,KAAK,EAAE,OAAO;EACd,UAAU,EAAE,OAAO,GACpB;;AACD,AAAA,UAAU,CAAC,qBAAqB,CAAC,wBAAwB,CAAC,OAAO,AAAA,SAAS,CAAC;EACzE,KAAK,EAAE,OAAO,GACf"
}

View File

@@ -0,0 +1 @@
#types{padding:3rem}#types .card-item{max-width:100%}#types .card-item p{margin:1rem 0 0}#directory .no-entries{padding:3rem}#directory .search-wrapper{margin:.5rem 1rem}#directory #pager .selectize-control{display:inline-block;vertical-align:middle}#directory .tablesorter-filter.disabled{display:none}#directory .edit-action{float:left}#directory .delete-action{float:right}#directory table{display:table}#directory thead{display:table-header-group}#directory tbody{display:table-row-group}#directory tr{display:table-row;-ms-flex:none;flex:none}#directory td,#directory th{display:table-cell;-ms-flex:none;flex:none}#directory td:focus,#directory th:focus{outline:0!important}#directory td{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}#directory td:last-child{text-align:right;white-space:nowrap}#directory td:last-child a{display:inline-block;float:none;padding-left:5px}#directory .tablesorter .filtered{display:none}#directory #pager{margin:50px 0;text-align:center}#directory #pager .pagedisplay{padding:0 10px}#directory #pager .button{border:0}#directory #pager input,#directory #pager select{display:inline-block;width:auto}#directory #pager select{vertical-align:middle}#directory .flex-list-pagination{display:-ms-flexbox;display:flex;margin:1rem 0 -1rem;padding:.5rem 1rem;-ms-flex-align:center;align-items:center}#directory .flex-list-pagination .flex-objects-pagination,#directory .flex-list-pagination .vuetable-pagination-info{-ms-flex:1;flex:1}#directory .flex-list-pagination .flex-objects-pagination{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;text-align:right;-khtml-user-select:none}#directory .flex-list-pagination .flex-objects-pagination .button{border-radius:4px;background:0 0}#directory .flex-list-pagination .flex-objects-pagination .button.disabled:active{margin:0}#pages-filters .checkboxes{-webkit-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none}#pages-filters .checkboxes.status-indeterminate input[type=checkbox]+label:before{font-family:FontAwesome,sans-serif;font-size:1.2rem;line-height:1;content:'\f00d';text-align:center}#directory .search-wrapper input,#directory .search-wrapper select{color:#646e7c;border:1px solid #e6e6e6;background-color:#fcfcfc}#directory .search-wrapper input::-webkit-input-placeholder,#directory .search-wrapper select::-webkit-input-placeholder{color:#a7afb8}#directory .search-wrapper input::-moz-placeholder,#directory .search-wrapper select::-moz-placeholder{color:#a7afb8}#directory .search-wrapper input:-moz-placeholder,#directory .search-wrapper select:-moz-placeholder{color:#a7afb8}#directory .search-wrapper input:-ms-input-placeholder,#directory .search-wrapper select:-ms-input-placeholder{color:#a7afb8}#directory .edit-action{color:#0090d9!important}#directory .delete-action{color:#f45857!important}#directory thead tr{background:#fafafa}#directory tbody tr:nth-child(even){background:#fff}#directory i.published{color:#27ae60}#directory i.unpublished{color:#e74c3c}#directory .flex-list-pagination{border-top:1px solid #f2f2f2;background:#fcfcfc}#directory .flex-list-pagination .flex-objects-pagination .button{color:#414147}#directory .flex-list-pagination .flex-objects-pagination .button.active{color:#fff;background:#0090d9}#directory .flex-list-pagination .flex-objects-pagination .button.disabled{color:#99a2ad}

View File

@@ -0,0 +1,27 @@
#flex-objects .text-center { text-align: center; }
#flex-objects .search { display: inline-block; width: 50%; padding: 0.7rem; }
#flex-objects button.sort { vertical-align: top; }
#flex-objects button.sort:after { font-family: FontAwesome; }
#flex-objects button.sort.asc:after { content: "\2191"; }
#flex-objects button.sort.desc:after { content: "\2193"; }
#flex-objects ul { margin: 3rem 0; }
#flex-objects ul li { display: inline-block; width: 33%; margin: 1rem 0; }
#flex-objects ul li .entry-details .name { font-weight: bold; font-size: 120%; }
#flex-objects ul li .entry-details p { margin: 0; }
#flex-objects ul li .entry-extra span { background: #eee; border-radius: 4px; padding: 3px 5px; font-size: 85%; }
@media only screen and (max-width: 800px) { #flex-objects ul li { width: 50%; } }
@media only screen and (max-width: 500px) { #flex-objects ul li { width: 100%; } }
/*# sourceMappingURL=data:application/json;charset=utf8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2l0ZS5jc3MiLCJzb3VyY2VzIjpbInNpdGUuc2NzcyIsInBsdWdpbi9fc2l0ZS5zY3NzIl0sInNvdXJjZXNDb250ZW50IjpbIi8vIExvYWQgTWFpbiBTdHlsZXNcbkBpbXBvcnQgXCJwbHVnaW4vc2l0ZVwiOyIsIiNmbGV4LW9iamVjdHMge1xuXG4gIC50ZXh0LWNlbnRlciB7XG4gICAgdGV4dC1hbGlnbjogY2VudGVyO1xuICB9XG5cbiAgLnNlYXJjaCB7XG4gICAgZGlzcGxheTogaW5saW5lLWJsb2NrO1xuICAgIHdpZHRoOiA1MCU7XG4gICAgcGFkZGluZzogMC43cmVtO1xuICB9XG5cbiAgYnV0dG9uLnNvcnQge1xuICAgIHZlcnRpY2FsLWFsaWduOiB0b3A7XG5cbiAgICAmOmFmdGVyIHtcbiAgICAgIGZvbnQtZmFtaWx5OiBGb250QXdlc29tZTtcbiAgICB9XG4gICAgJi5hc2M6YWZ0ZXIge1xuICAgICAgY29udGVudDogXCJcXDIxOTFcIjtcbiAgICB9XG4gICAgJi5kZXNjOmFmdGVyIHtcbiAgICAgIGNvbnRlbnQ6IFwiXFwyMTkzXCI7XG4gICAgfVxuICB9XG5cbiAgdWwge1xuICAgIG1hcmdpbjogM3JlbSAwO1xuXG4gICAgbGkge1xuICAgICAgZGlzcGxheTogaW5saW5lLWJsb2NrO1xuICAgICAgd2lkdGg6IDMzJTtcbiAgICAgIG1hcmdpbjogMXJlbSAwO1xuXG4gICAgICAuZW50cnktZGV0YWlscyB7XG5cbiAgICAgICAgLm5hbWUge1xuICAgICAgICAgIGZvbnQtd2VpZ2h0OiBib2xkO1xuICAgICAgICAgIGZvbnQtc2l6ZTogMTIwJTtcbiAgICAgICAgfVxuICAgICAgICBwIHtcbiAgICAgICAgICBtYXJnaW46IDA7XG4gICAgICAgIH1cbiAgICAgIH1cblxuICAgICAgLmVudHJ5LWV4dHJhIHtcbiAgICAgICAgc3BhbiB7XG4gICAgICAgICAgYmFja2dyb3VuZDogI2VlZTtcbiAgICAgICAgICBib3JkZXItcmFkaXVzOiA0cHg7XG4gICAgICAgICAgcGFkZGluZzogM3B4IDVweDtcbiAgICAgICAgICBmb250LXNpemU6IDg1JTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH1cbiAgfVxufVxuXG5AbWVkaWEgb25seSBzY3JlZW4gYW5kIChtYXgtd2lkdGg6IDgwMHB4KSB7XG4gICNmbGV4LW9iamVjdHMgdWwgbGkge1xuICAgIHdpZHRoOiA1MCU7XG4gIH1cbn1cblxuQG1lZGlhIG9ubHkgc2NyZWVuIGFuZCAobWF4LXdpZHRoOiA1MDBweCkge1xuICAjZmxleC1vYmplY3RzIHVsIGxpIHtcbiAgICB3aWR0aDogMTAwJTtcbiAgfVxufSJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUNBQSxBQUVFLGFBRlcsQ0FFWCxZQUFZLENBQUMsRUFDWCxVQUFVLEVBQUUsTUFBTSxHQUNuQjs7QUFKSCxBQU1FLGFBTlcsQ0FNWCxPQUFPLENBQUMsRUFDTixPQUFPLEVBQUUsWUFBWSxFQUNyQixLQUFLLEVBQUUsR0FBRyxFQUNWLE9BQU8sRUFBRSxNQUFNLEdBQ2hCOztBQVZILEFBWUUsYUFaVyxDQVlYLE1BQU0sQUFBQSxLQUFLLENBQUMsRUFDVixjQUFjLEVBQUUsR0FBRyxHQVdwQjs7QUF4QkgsQUFlSSxhQWZTLENBWVgsTUFBTSxBQUFBLEtBQUssQUFHUixNQUFNLENBQUMsRUFDTixXQUFXLEVBQUUsV0FBVyxHQUN6Qjs7QUFqQkwsQUFrQkksYUFsQlMsQ0FZWCxNQUFNLEFBQUEsS0FBSyxBQU1SLElBQUksQUFBQSxNQUFNLENBQUMsRUFDVixPQUFPLEVBQUUsT0FBTyxHQUNqQjs7QUFwQkwsQUFxQkksYUFyQlMsQ0FZWCxNQUFNLEFBQUEsS0FBSyxBQVNSLEtBQUssQUFBQSxNQUFNLENBQUMsRUFDWCxPQUFPLEVBQUUsT0FBTyxHQUNqQjs7QUF2QkwsQUEwQkUsYUExQlcsQ0EwQlgsRUFBRSxDQUFDLEVBQ0QsTUFBTSxFQUFFLE1BQU0sR0EyQmY7O0FBdERILEFBNkJJLGFBN0JTLENBMEJYLEVBQUUsQ0FHQSxFQUFFLENBQUMsRUFDRCxPQUFPLEVBQUUsWUFBWSxFQUNyQixLQUFLLEVBQUUsR0FBRyxFQUNWLE1BQU0sRUFBRSxNQUFNLEdBcUJmOztBQXJETCxBQW9DUSxhQXBDSyxDQTBCWCxFQUFFLENBR0EsRUFBRSxDQUtBLGNBQWMsQ0FFWixLQUFLLENBQUMsRUFDSixXQUFXLEVBQUUsSUFBSSxFQUNqQixTQUFTLEVBQUUsSUFBSSxHQUNoQjs7QUF2Q1QsQUF3Q1EsYUF4Q0ssQ0EwQlgsRUFBRSxDQUdBLEVBQUUsQ0FLQSxjQUFjLENBTVosQ0FBQyxDQUFDLEVBQ0EsTUFBTSxFQUFFLENBQUMsR0FDVjs7QUExQ1QsQUE4Q1EsYUE5Q0ssQ0EwQlgsRUFBRSxDQUdBLEVBQUUsQ0FnQkEsWUFBWSxDQUNWLElBQUksQ0FBQyxFQUNILFVBQVUsRUFBRSxJQUFJLEVBQ2hCLGFBQWEsRUFBRSxHQUFHLEVBQ2xCLE9BQU8sRUFBRSxPQUFPLEVBQ2hCLFNBQVMsRUFBRSxHQUFHLEdBQ2Y7O0FBTVQsTUFBTSxNQUFNLE1BQU0sTUFBTSxTQUFTLEVBQUUsS0FBSyxJQUN0QyxBQUFBLGFBQWEsQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUFDLEVBQ2xCLEtBQUssRUFBRSxHQUFHLEdBQ1g7O0FBR0gsTUFBTSxNQUFNLE1BQU0sTUFBTSxTQUFTLEVBQUUsS0FBSyxJQUN0QyxBQUFBLGFBQWEsQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUFDLEVBQ2xCLEtBQUssRUFBRSxJQUFJLEdBQ1oifQ== */

View File

@@ -0,0 +1,11 @@
{
"version": 3,
"file": "site.css",
"sources": [
"../scss/site.scss",
"../hdr0",
"../scss/plugin/_site.scss"
],
"names": [],
"mappings": "AEAA,AAEE,aAFW,CAEX,YAAY,CAAC;EACX,UAAU,EAAE,MAAM,GACnB;;AAJH,AAME,aANW,CAMX,OAAO,CAAC;EACN,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,GAAG;EACV,OAAO,EAAE,MAAM,GAChB;;AAVH,AAYE,aAZW,CAYX,MAAM,AAAA,KAAK,CAAC;EACV,cAAc,EAAE,GAAG,GAWpB;EAxBH,AAeI,aAfS,CAYX,MAAM,AAAA,KAAK,AAGR,MAAM,CAAC;IACN,WAAW,EAAE,WAAW,GACzB;EAjBL,AAkBI,aAlBS,CAYX,MAAM,AAAA,KAAK,AAMR,IAAI,AAAA,MAAM,CAAC;IACV,OAAO,EAAE,OAAO,GACjB;EApBL,AAqBI,aArBS,CAYX,MAAM,AAAA,KAAK,AASR,KAAK,AAAA,MAAM,CAAC;IACX,OAAO,EAAE,OAAO,GACjB;;AAvBL,AA0BE,aA1BW,CA0BX,EAAE,CAAC;EACD,MAAM,EAAE,MAAM,GA2Bf;EAtDH,AA6BI,aA7BS,CA0BX,EAAE,CAGA,EAAE,CAAC;IACD,OAAO,EAAE,YAAY;IACrB,KAAK,EAAE,GAAG;IACV,MAAM,EAAE,MAAM,GAqBf;IArDL,AAoCQ,aApCK,CA0BX,EAAE,CAGA,EAAE,CAKA,cAAc,CAEZ,KAAK,CAAC;MACJ,WAAW,EAAE,IAAI;MACjB,SAAS,EAAE,IAAI,GAChB;IAvCT,AAwCQ,aAxCK,CA0BX,EAAE,CAGA,EAAE,CAKA,cAAc,CAMZ,CAAC,CAAC;MACA,MAAM,EAAE,CAAC,GACV;IA1CT,AA8CQ,aA9CK,CA0BX,EAAE,CAGA,EAAE,CAgBA,YAAY,CACV,IAAI,CAAC;MACH,UAAU,EAAE,IAAI;MAChB,aAAa,EAAE,GAAG;MAClB,OAAO,EAAE,OAAO;MAChB,SAAS,EAAE,GAAG,GACf;;AAMT,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,KAAK;EACtC,AAAA,aAAa,CAAC,EAAE,CAAC,EAAE,CAAC;IAClB,KAAK,EAAE,GAAG,GACX;;AAGH,MAAM,MAAM,MAAM,MAAM,SAAS,EAAE,KAAK;EACtC,AAAA,aAAa,CAAC,EAAE,CAAC,EAAE,CAAC;IAClB,KAAK,EAAE,IAAI,GACZ"
}

View File

@@ -0,0 +1 @@
#flex-objects .text-center{text-align:center}#flex-objects .search{display:inline-block;width:50%;padding:.7rem}#flex-objects button.sort{vertical-align:top}#flex-objects button.sort:after{font-family:FontAwesome}#flex-objects button.sort.asc:after{content:'\2191'}#flex-objects button.sort.desc:after{content:'\2193'}#flex-objects ul{margin:3rem 0}#flex-objects ul li{display:inline-block;width:33%;margin:1rem 0}#flex-objects ul li .entry-details .name{font-size:120%;font-weight:700}#flex-objects ul li .entry-details p{margin:0}#flex-objects ul li .entry-extra span{font-size:85%;padding:3px 5px;border-radius:4px;background:#eee}@media only screen and (max-width:800px){#flex-objects ul li{width:50%}}@media only screen and (max-width:500px){#flex-objects ul li{width:100%}}

Some files were not shown because too many files have changed in this diff Show More