├── .eslintignore
├── src
├── js
│ ├── layouts
│ │ ├── index.html
│ │ └── adminDefault.vue
│ ├── library
│ │ ├── translate.js
│ │ ├── bootData.js
│ │ ├── loadDefinitionType.js
│ │ ├── eventBus.js
│ │ ├── formatDate.js
│ │ ├── api.js
│ │ ├── dialog.js
│ │ ├── addModal.js
│ │ ├── googleMaps.js
│ │ ├── dataBag.js
│ │ ├── resource.js
│ │ ├── userPreferences.js
│ │ ├── viewport.js
│ │ ├── loadControllerType.js
│ │ ├── serviceContainer.js
│ │ ├── getVueComponentMapper.js
│ │ ├── loader.js
│ │ ├── browserFeatures.js
│ │ ├── ckeditor.js
│ │ └── auth.js
│ ├── formElements
│ │ ├── hidden.vue
│ │ ├── text.vue
│ │ ├── number.vue
│ │ ├── dateTime.vue
│ │ ├── color.vue
│ │ ├── htmlComponents
│ │ │ ├── baseComponent.vue
│ │ │ └── componentControls.vue
│ │ ├── textarea.vue
│ │ ├── checkbox.vue
│ │ ├── nestedSelect
│ │ │ └── treeNode.vue
│ │ ├── code.vue
│ │ └── base.vue
│ ├── mixins
│ │ ├── screenSize.js
│ │ ├── emitter.js
│ │ └── headerBorderListener.js
│ ├── listElements
│ │ ├── date.vue
│ │ ├── text.vue
│ │ ├── dateTime.vue
│ │ ├── blip.vue
│ │ ├── link.vue
│ │ ├── button.vue
│ │ └── base.js
│ ├── components
│ │ ├── appView.vue
│ │ ├── resourceEdit
│ │ │ ├── group.vue
│ │ │ ├── region.vue
│ │ │ └── tab.vue
│ │ ├── resourceSort.vue
│ │ ├── resourceListTable.vue
│ │ ├── loader.vue
│ │ ├── dialogModal.vue
│ │ ├── pagePreview.vue
│ │ ├── resourceListCards.vue
│ │ ├── resourceListTree.vue
│ │ └── message.vue
│ ├── store.js
│ ├── controllers
│ │ ├── error.vue
│ │ └── baseNestedResource.vue
│ ├── dependencies
│ │ └── vue-dismiss
│ │ │ └── index.js
│ ├── appServices.js
│ └── app.js
├── font
│ ├── icons
│ │ ├── fonts
│ │ │ ├── trim.eot
│ │ │ ├── trim.ttf
│ │ │ └── trim.woff
│ │ ├── Read Me.txt
│ │ └── demo-files
│ │ │ ├── demo.js
│ │ │ └── demo.css
│ └── webFonts
│ │ ├── roboto-400-italic.eot
│ │ ├── roboto-400-italic.ttf
│ │ ├── roboto-400-normal.eot
│ │ ├── roboto-400-normal.ttf
│ │ ├── roboto-700-italic.eot
│ │ ├── roboto-700-italic.ttf
│ │ ├── roboto-700-normal.eot
│ │ ├── roboto-700-normal.ttf
│ │ ├── roboto-400-italic.woff
│ │ ├── roboto-400-normal.woff
│ │ ├── roboto-700-italic.woff
│ │ ├── roboto-700-normal.woff
│ │ ├── archivo-narrow-400-italic.eot
│ │ ├── archivo-narrow-400-italic.ttf
│ │ ├── archivo-narrow-400-normal.eot
│ │ ├── archivo-narrow-400-normal.ttf
│ │ ├── archivo-narrow-700-italic.eot
│ │ ├── archivo-narrow-700-italic.ttf
│ │ ├── archivo-narrow-700-normal.eot
│ │ ├── archivo-narrow-700-normal.ttf
│ │ ├── roboto-400-italic-greek.woff2
│ │ ├── roboto-400-italic-latin.woff2
│ │ ├── roboto-400-normal-greek.woff2
│ │ ├── roboto-400-normal-latin.woff2
│ │ ├── roboto-700-italic-greek.woff2
│ │ ├── roboto-700-italic-latin.woff2
│ │ ├── roboto-700-normal-greek.woff2
│ │ ├── roboto-700-normal-latin.woff2
│ │ ├── archivo-narrow-400-italic.woff
│ │ ├── archivo-narrow-400-normal.woff
│ │ ├── archivo-narrow-700-italic.woff
│ │ ├── archivo-narrow-700-normal.woff
│ │ ├── roboto-400-italic-cyrillic.woff2
│ │ ├── roboto-400-italic-greek-ext.woff2
│ │ ├── roboto-400-italic-latin-ext.woff2
│ │ ├── roboto-400-italic-vietnamese.woff2
│ │ ├── roboto-400-normal-cyrillic.woff2
│ │ ├── roboto-400-normal-greek-ext.woff2
│ │ ├── roboto-400-normal-latin-ext.woff2
│ │ ├── roboto-400-normal-vietnamese.woff2
│ │ ├── roboto-700-italic-cyrillic.woff2
│ │ ├── roboto-700-italic-greek-ext.woff2
│ │ ├── roboto-700-italic-latin-ext.woff2
│ │ ├── roboto-700-italic-vietnamese.woff2
│ │ ├── roboto-700-normal-cyrillic.woff2
│ │ ├── roboto-700-normal-greek-ext.woff2
│ │ ├── roboto-700-normal-latin-ext.woff2
│ │ ├── roboto-700-normal-vietnamese.woff2
│ │ ├── roboto-400-italic-cyrillic-ext.woff2
│ │ ├── roboto-400-normal-cyrillic-ext.woff2
│ │ ├── roboto-700-italic-cyrillic-ext.woff2
│ │ ├── roboto-700-normal-cyrillic-ext.woff2
│ │ ├── archivo-narrow-400-italic-latin.woff2
│ │ ├── archivo-narrow-400-normal-latin.woff2
│ │ ├── archivo-narrow-700-italic-latin.woff2
│ │ ├── archivo-narrow-700-normal-latin.woff2
│ │ ├── archivo-narrow-400-italic-latin-ext.woff2
│ │ ├── archivo-narrow-400-normal-latin-ext.woff2
│ │ ├── archivo-narrow-700-italic-latin-ext.woff2
│ │ └── archivo-narrow-700-normal-latin-ext.woff2
└── scss
│ ├── library
│ ├── _all.scss
│ ├── helpers
│ │ ├── _hideText.scss
│ │ ├── _hover.scss
│ │ ├── _untruncate.scss
│ │ ├── _all.scss
│ │ ├── _pageEmEquivalent.scss
│ │ ├── _normalizeButton.scss
│ │ ├── _mediaQueries.scss
│ │ ├── _normalizePlusReset.scss
│ │ └── _iconFont.scss
│ ├── _projectFunctions.scss
│ └── _projectMixins.scss
│ ├── partials
│ ├── _icons.scss
│ ├── _animations.scss
│ ├── _core.scss
│ └── _webfonts.scss
│ └── bundles
│ └── _main.scss
├── server
├── nodePlaceholder.js
├── previewPages.js
├── node.js
└── client.js
├── .husky
└── pre-commit
├── documentation
├── public
│ ├── favicon.ico
│ ├── favicon-196x196.png
│ └── apple-touch-icon-152x152.png
├── .vitepress
│ ├── theme
│ │ ├── index.js
│ │ └── custom.scss
│ ├── config.js
│ └── assets
│ │ └── trikoder.svg
├── index.md
├── getting-started.md
├── about.md
└── adding-resource.md
├── postcss.config.js
├── .babelrc.js
├── .editorconfig
├── .travis.yml
├── demo
├── scss
│ ├── main.scss
│ └── _variables.scss
├── dummy.vue
├── routes.js
├── index.html
├── services.js
├── controllers
│ ├── mySettings.js
│ ├── tag.js
│ ├── article
│ │ ├── quoteInContent.vue
│ │ └── imageInContent.vue
│ ├── snippet.js
│ ├── user.js
│ └── media.js
├── auth.js
├── main.js
└── mainNavigation.js
├── .gitignore
├── README.md
├── LICENSE
├── .github
└── workflows
│ └── ci.yml
├── vite.config.js
├── .eslintrc.cjs
└── package.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | docs/
2 |
--------------------------------------------------------------------------------
/src/js/layouts/index.html:
--------------------------------------------------------------------------------
1 | Pokos je pripremio ovaj tekst iz baze
--------------------------------------------------------------------------------
/server/nodePlaceholder.js:
--------------------------------------------------------------------------------
1 | export default {
2 | resetData() {}
3 | };
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/src/font/icons/fonts/trim.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/icons/fonts/trim.eot
--------------------------------------------------------------------------------
/src/font/icons/fonts/trim.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/icons/fonts/trim.ttf
--------------------------------------------------------------------------------
/src/js/library/translate.js:
--------------------------------------------------------------------------------
1 | import translate from 'translate-js';
2 |
3 | export default translate;
4 |
--------------------------------------------------------------------------------
/documentation/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/documentation/public/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | 'postcss-preset-env': {}
4 | }
5 | };
6 |
--------------------------------------------------------------------------------
/src/font/icons/fonts/trim.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/icons/fonts/trim.woff
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic.eot
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic.ttf
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal.eot
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal.ttf
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic.eot
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic.ttf
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal.eot
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal.ttf
--------------------------------------------------------------------------------
/documentation/public/favicon-196x196.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/documentation/public/favicon-196x196.png
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic.woff
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal.woff
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic.woff
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal.woff
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-italic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-italic.eot
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-italic.ttf
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-normal.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-normal.eot
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-normal.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-normal.ttf
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-italic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-italic.eot
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-italic.ttf
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-normal.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-normal.eot
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-normal.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-normal.ttf
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic-greek.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic-greek.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic-latin.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal-greek.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal-greek.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal-latin.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic-greek.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic-greek.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic-latin.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal-greek.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal-greek.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal-latin.woff2
--------------------------------------------------------------------------------
/src/scss/library/_all.scss:
--------------------------------------------------------------------------------
1 | @forward 'helpers/all';
2 | @forward 'variables';
3 | @forward 'projectMixins';
4 | @forward 'projectFunctions';
5 |
--------------------------------------------------------------------------------
/documentation/public/apple-touch-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/documentation/public/apple-touch-icon-152x152.png
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-italic.woff
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-normal.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-normal.woff
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-italic.woff
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-normal.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-normal.woff
--------------------------------------------------------------------------------
/documentation/.vitepress/theme/index.js:
--------------------------------------------------------------------------------
1 | import DefaultTheme from 'vitepress/theme';
2 | import './custom.scss';
3 |
4 | export default DefaultTheme;
5 |
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic-cyrillic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic-cyrillic.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic-greek-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic-greek-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic-latin-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic-vietnamese.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic-vietnamese.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal-cyrillic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal-cyrillic.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal-greek-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal-greek-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal-latin-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal-vietnamese.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal-vietnamese.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic-cyrillic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic-cyrillic.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic-greek-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic-greek-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic-latin-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic-vietnamese.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic-vietnamese.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal-cyrillic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal-cyrillic.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal-greek-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal-greek-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal-latin-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal-vietnamese.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal-vietnamese.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-italic-cyrillic-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-italic-cyrillic-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-400-normal-cyrillic-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-400-normal-cyrillic-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-italic-cyrillic-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-italic-cyrillic-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/roboto-700-normal-cyrillic-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/roboto-700-normal-cyrillic-ext.woff2
--------------------------------------------------------------------------------
/src/scss/library/helpers/_hideText.scss:
--------------------------------------------------------------------------------
1 | // Hide text
2 |
3 | @mixin hideText {
4 | display: inline-block; text-indent: -9999em; overflow: hidden;
5 | }
6 |
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-italic-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-italic-latin.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-normal-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-normal-latin.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-italic-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-italic-latin.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-normal-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-normal-latin.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-italic-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-italic-latin-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-400-normal-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-400-normal-latin-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-italic-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-italic-latin-ext.woff2
--------------------------------------------------------------------------------
/src/font/webFonts/archivo-narrow-700-normal-latin-ext.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trikoder/trim/HEAD/src/font/webFonts/archivo-narrow-700-normal-latin-ext.woff2
--------------------------------------------------------------------------------
/src/scss/library/helpers/_hover.scss:
--------------------------------------------------------------------------------
1 | // Hover and focus shortcut
2 |
3 | @mixin hover {
4 | &:hover,
5 | &:focus {
6 | @content;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/scss/partials/_icons.scss:
--------------------------------------------------------------------------------
1 | @use '../library/all' as *;
2 |
3 | @include defineIconFont($iconFontName, asset('font/icons/fonts/trim'), $iconFontVersion, $icons);
4 |
--------------------------------------------------------------------------------
/.babelrc.js:
--------------------------------------------------------------------------------
1 | export default {
2 | "presets": [
3 | "@babel/preset-env"
4 | ],
5 | "env": {
6 | "test": {
7 | "plugins": ["istanbul"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/documentation/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: home
3 | ---
4 |
5 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 4
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/src/scss/library/helpers/_untruncate.scss:
--------------------------------------------------------------------------------
1 | // (un)truncate text helper
2 |
3 | @mixin untruncate {
4 |
5 | max-width: none; overflow: visible; text-overflow: clip; white-space: normal;
6 |
7 | }
8 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "8"
4 | dist: trusty # needs Ubuntu Trusty
5 | sudo: required # no need for virtualization.
6 | addons:
7 | chrome: stable # have Travis install chrome stable.
--------------------------------------------------------------------------------
/demo/scss/main.scss:
--------------------------------------------------------------------------------
1 | //--------------------------------------------------------------
2 | // Core styles
3 | //--------------------------------------------------------------
4 | @use '../../src/scss/bundles/main';
5 |
--------------------------------------------------------------------------------
/server/previewPages.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | const app = express();
3 |
4 | app.use('/trim', express.static('docs'));
5 |
6 | app.listen(3000, () => console.log(
7 | 'Preview app on http://localhost:3000/trim'
8 | ));
9 |
--------------------------------------------------------------------------------
/src/scss/library/helpers/_all.scss:
--------------------------------------------------------------------------------
1 | @forward 'hideText';
2 | @forward 'hover';
3 | @forward 'iconFont';
4 | @forward 'mediaQueries';
5 | @forward 'normalizeButton';
6 | @forward 'normalizePlusReset';
7 | @forward 'pageEmEquivalent';
8 | @forward 'untruncate';
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules/
3 | coverage/
4 | npm-debug.log*
5 | docs
6 | .temp
7 | documentation/.vitepress/cache
8 |
9 | # Editor directories and files
10 | .idea
11 | .vscode
12 | *.orig
13 | *.suo
14 | *.ntvs*
15 | *.njsproj
16 | *.sln
17 |
--------------------------------------------------------------------------------
/src/scss/library/helpers/_pageEmEquivalent.scss:
--------------------------------------------------------------------------------
1 | // Set base em value for page in pixel relative unit
2 |
3 | @use 'sass:math';
4 |
5 | @mixin pageEmEquivalent($pixels: 10) {
6 | html { font-size: math.percentage(math.div($pixels, 16)); }
7 | body { font-size: 1em; }
8 | }
9 |
--------------------------------------------------------------------------------
/src/scss/library/helpers/_normalizeButton.scss:
--------------------------------------------------------------------------------
1 | // Normalized button
2 |
3 | @mixin normalizeButton {
4 |
5 | margin:0; padding: 0; border: 0; cursor: pointer; background: none;
6 |
7 | &::-moz-focus-inner {
8 | padding: 0; border: 0;
9 | }
10 |
11 | }
12 |
--------------------------------------------------------------------------------
/demo/scss/_variables.scss:
--------------------------------------------------------------------------------
1 | //--------------------------------------------------------------
2 | // Variable overrides
3 | //--------------------------------------------------------------
4 |
5 | @forward 'trim/scss/library/variables' with (
6 | $assetBasePath: '../../src/' !default
7 | );
8 |
--------------------------------------------------------------------------------
/demo/dummy.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
13 |
18 |
--------------------------------------------------------------------------------
/src/js/library/bootData.js:
--------------------------------------------------------------------------------
1 | import DataBag from './dataBag.js';
2 | import {assign} from './toolkit.js';
3 |
4 | const store = new DataBag();
5 |
6 | export default assign(function(key, defaultValue) {
7 | return store.get(key, defaultValue);
8 | }, {
9 | set(data) {
10 | store.set(data);
11 | return this;
12 | }
13 | });
14 |
--------------------------------------------------------------------------------
/src/js/library/loadDefinitionType.js:
--------------------------------------------------------------------------------
1 | import serviceContainer from './serviceContainer.js';
2 |
3 | export default function(definition) {
4 | return typeof definition.Type === 'string'
5 | ? serviceContainer.get(definition.Type).then(Type => {
6 | definition.Type = Type;
7 | return definition;
8 | })
9 | : Promise.resolve(definition);
10 | }
11 |
--------------------------------------------------------------------------------
/src/js/formElements/hidden.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
24 |
--------------------------------------------------------------------------------
/src/js/library/eventBus.js:
--------------------------------------------------------------------------------
1 | import mitt from 'mitt';
2 |
3 | const emitter = mitt();
4 |
5 | function publish(eventName, data) {
6 | emitter.emit(eventName, data);
7 | }
8 |
9 | function subscribe(eventName, callback) {
10 | emitter.on(eventName, callback);
11 | }
12 |
13 | function unsubscribe(eventName, callback) {
14 | emitter.off(eventName, callback);
15 | }
16 |
17 | export {publish, subscribe, unsubscribe};
18 |
--------------------------------------------------------------------------------
/src/js/mixins/screenSize.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | computed: {
4 |
5 | breakpoint() {
6 |
7 | return this.$store.state.breakpoint;
8 |
9 | },
10 |
11 | screenIsSmall() {
12 |
13 | return this.breakpoint === 'small';
14 |
15 | },
16 | screenIsLarge() {
17 |
18 | return this.breakpoint === 'large';
19 |
20 | }
21 |
22 | }
23 |
24 | };
25 |
--------------------------------------------------------------------------------
/demo/routes.js:
--------------------------------------------------------------------------------
1 | export default router => {
2 |
3 | router.controller('/', 'dashboard', 'Article@index');
4 |
5 | router.resource('article');
6 | router.resource('page');
7 | router.resource('user');
8 | router.resource('tag');
9 | router.resource('category');
10 | router.resource('snippet');
11 | router.resource({name: 'media', hasCreateRoute: ['image', 'videoEmbed', 'file']});
12 |
13 | router.controller('my-settings', 'mySettings', 'MySettings');
14 |
15 | };
16 |
--------------------------------------------------------------------------------
/src/js/library/formatDate.js:
--------------------------------------------------------------------------------
1 | import {format as formatDate} from 'fecha';
2 |
3 | export default function(dateInput, format) {
4 |
5 | try {
6 |
7 | return dateInput
8 | ? formatDate(dateInput instanceof Date ? dateInput : new Date(dateInput), format)
9 | : ''
10 | ;
11 |
12 | } catch (error) {
13 |
14 | console.log('Date value "' + dateInput + '" is not in correct format!'); // eslint-disable-line no-console
15 |
16 | }
17 |
18 | };
19 |
--------------------------------------------------------------------------------
/src/js/library/api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import bootData from './bootData.js';
3 |
4 | const api = axios.create();
5 |
6 | api.interceptors.request.use(config => {
7 |
8 | config.baseURL = bootData('baseApiUrl', '/api/');
9 | config.headers['Content-Type'] = 'application/vnd.api+json';
10 |
11 | if (bootData('usePatchForUpdate') && config.method === 'put') {
12 | config.method = 'patch';
13 | }
14 |
15 | return config;
16 |
17 | });
18 |
19 | export default api;
20 |
--------------------------------------------------------------------------------
/src/js/library/dialog.js:
--------------------------------------------------------------------------------
1 | import {assign, isPlainObject} from '../library/toolkit.js';
2 | import DialogModal from '../components/dialogModal.vue';
3 |
4 | export function confirm(message, onAccept, config) {
5 |
6 | let params;
7 |
8 | if (isPlainObject(message)) {
9 | params = message;
10 | } else if (typeof message === 'function') {
11 | params = {onAccept: message};
12 | } else {
13 | params = assign({}, config, {message, onAccept});
14 | }
15 |
16 | return DialogModal.open(params);
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/src/scss/bundles/_main.scss:
--------------------------------------------------------------------------------
1 | //--------------------------------------------------------------
2 | // Vendor css
3 | //--------------------------------------------------------------
4 | @use 'normalize.css';
5 |
6 | //--------------------------------------------------------------
7 | // Main partials and components
8 | //--------------------------------------------------------------
9 | @use '../partials/icons';
10 | @use '../partials/webfonts';
11 | @use '../partials/core';
12 | @use '../partials/animations';
13 | @use '../partials/resourceList';
14 | @use '../partials/resourceEdit';
15 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Trikoder admin
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/scss/library/helpers/_mediaQueries.scss:
--------------------------------------------------------------------------------
1 | @use '../projectFunctions' as *;
2 |
3 | // Media query helpers
4 |
5 | @mixin mediaMinWidth($breakpoint) {
6 |
7 | @media screen and (min-width: em($breakpoint + 1)) {
8 | @content
9 | }
10 |
11 | }
12 |
13 | @mixin mediaMaxWidth($breakpoint) {
14 |
15 | @media screen and (max-width: em($breakpoint)) {
16 | @content
17 | }
18 |
19 | }
20 |
21 | @mixin mediaMinToMaxWidth($breakpoint1, $breakpoint2) {
22 |
23 | @media screen and (min-width: em($breakpoint1 + 1)) and (max-width: em($breakpoint2)) {
24 | @content
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/documentation/.vitepress/theme/custom.scss:
--------------------------------------------------------------------------------
1 | $accentColor: #e03431;
2 |
3 | :root {
4 | --vp-code-block-bg: #161618;
5 | }
6 |
7 | @media screen and (min-width: 720px) {
8 |
9 | .VPNavBar .VPNavBarTitle a {
10 |
11 | display: block; position: relative;
12 | line-height: 64px; padding-left: 65px;
13 |
14 | }
15 |
16 | .VPNavBar .VPNavBarTitle a:before {
17 |
18 | position: absolute; content: ''; left: 0; top: 50%; transform: translateY(-50%); width: 44px; height: 44px;
19 | background: #e03431 url('../assets/trikoder.svg') no-repeat center; border-radius: 50%;
20 | background-size: 30px;
21 |
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/src/js/library/addModal.js:
--------------------------------------------------------------------------------
1 | let container;
2 |
3 | function addModal(providers) {
4 | if (window.document.activeElement) {
5 | window.document.activeElement.blur();
6 | }
7 | return container.addModal(providers);
8 | }
9 |
10 | export function setModalContainer(wrapper) {
11 | container = wrapper;
12 | }
13 |
14 | export function guid() {
15 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(character) {
16 | const random = Math.random() * 16 | 0;
17 | const value = character === 'x' ? random : (random & 0x3 | 0x8);
18 | return value.toString(16);
19 | });
20 | }
21 |
22 | export default addModal;
23 |
--------------------------------------------------------------------------------
/demo/services.js:
--------------------------------------------------------------------------------
1 | import MainNavigation from './mainNavigation.js';
2 |
3 | export default {
4 | MainNavigation: () => MainNavigation,
5 | PageController: () => import('./controllers/page.js'),
6 | ArticleController: () => import('./controllers/article/index.js'),
7 | TagController: () => import('./controllers/tag.js'),
8 | UserController: () => import('./controllers/user.js'),
9 | CategoryController: () => import('./controllers/category.js'),
10 | MediaController: () => import('./controllers/media.js'),
11 | SnippetController: () => import('./controllers/snippet.js'),
12 | MySettingsController: () => import('./controllers/mySettings.js')
13 | };
14 |
--------------------------------------------------------------------------------
/src/scss/library/_projectFunctions.scss:
--------------------------------------------------------------------------------
1 | @use 'variables' as *;
2 | @use 'sass:math';
3 | @use 'sass:color';
4 |
5 | @function asset($string) {
6 | @return $assetBasePath + $string;
7 | }
8 |
9 | @function em($pxval, $base: 16) {
10 | @return divide($pxval, $base) * 1em;
11 | }
12 |
13 | @function rem($pxval) {
14 | @return divide($pxval, 10) * 1rem;
15 | }
16 |
17 | @function divide($val1, $val2) {
18 | @return math.div($val1, $val2);
19 | }
20 |
21 | @function darken($color, $amount) {
22 | @return color.adjust($color, $lightness: -$amount)
23 | }
24 |
25 | @function lighten($color, $amount) {
26 | @return color.adjust($color, $lightness: $amount)
27 | }
28 |
--------------------------------------------------------------------------------
/src/js/library/googleMaps.js:
--------------------------------------------------------------------------------
1 | import * as pkg from '@googlemaps/js-api-loader';
2 | import bootData from '../library/bootData.js';
3 | import app from '../app.js';
4 |
5 | const __default = 'default';
6 | const Loader = pkg.Loader || pkg[__default].Loader;
7 |
8 | let loadApiPromise;
9 |
10 | export function loadApi() {
11 |
12 | if (loadApiPromise) {
13 | return loadApiPromise;
14 | }
15 |
16 | const loader = new Loader({
17 | apiKey: bootData('googleMapsApiKey'),
18 | libraries: ['places'],
19 | language: app.getLocale()
20 | });
21 |
22 | loadApiPromise = loader.load().then((googleLib) => googleLib);
23 |
24 | return loadApiPromise;
25 |
26 | };
27 |
--------------------------------------------------------------------------------
/demo/controllers/mySettings.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | extendsController: 'BaseResourceEdit',
4 | cssClass: 'mySettingsController',
5 |
6 | getModel: Model => Model.getFromApi({type: 'user', id: 1}),
7 |
8 | setupEdit({edit}) {
9 |
10 | this.setPageTitle('My settings');
11 |
12 | this.addControl({
13 | caption: 'Documentation',
14 | action: () => window.open('/trim/', '_blank')
15 | });
16 |
17 | this.addSaveControl();
18 |
19 | edit.addField('TextFormElement', {
20 | label: 'Email',
21 | name: 'email',
22 | attributes: {input: {class: 'inputType2 size2'}}
23 | });
24 |
25 | }
26 |
27 | };
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Trikoder Trim
2 | [](https://www.npmjs.com/package/@trikoder/trim)
3 |
4 | Responsive user interface framework for building content management systems with simple authoring API.
5 | Designed to run as a browser application connected to JSON:API powered backend.
6 |
7 | ## Documentation and demo
8 | [Browse documentation and demo pages](https://trikoder.github.io/trim).
9 |
10 | ## Changelog
11 | Available [here](https://trikoder.github.io/trim/changelog.html).
12 |
13 | ## Credits
14 | Copyright (C) 2018 Trikoder
15 |
16 | Authors: Damir Brekalo, Davor Erić, Ivan Nikolić, Alen Pokos, Vedran Križek.
17 |
18 | ## License
19 | Package is licensed under [MIT License](./LICENSE)
20 |
--------------------------------------------------------------------------------
/src/js/library/dataBag.js:
--------------------------------------------------------------------------------
1 | import {assign} from './toolkit.js';
2 |
3 | function DataBag(data) {
4 | this.store = assign({}, data);
5 | }
6 |
7 | assign(DataBag.prototype, {
8 |
9 | get(key, defaultValue) {
10 |
11 | const pieces = key.split('.');
12 | let haystack = this.store;
13 |
14 | for (const i in pieces) {
15 |
16 | haystack = haystack[pieces[i]];
17 |
18 | if (typeof haystack === 'undefined') {
19 | return defaultValue;
20 | }
21 | }
22 |
23 | return haystack;
24 |
25 | },
26 |
27 | set(data) {
28 |
29 | assign(this.store, data);
30 | return this;
31 |
32 | }
33 |
34 | });
35 |
36 | export default DataBag;
37 |
--------------------------------------------------------------------------------
/src/js/formElements/text.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
13 |
31 |
--------------------------------------------------------------------------------
/src/js/library/resource.js:
--------------------------------------------------------------------------------
1 | import {Model, Collection} from 'json-api-resource';
2 | import httpMixin from 'json-api-resource/lib/httpMixin.js';
3 | import bootData from './bootData.js';
4 | import {publish} from './eventBus.js';
5 | import api from './api.js';
6 |
7 | httpMixin({
8 | Model,
9 | Collection,
10 | baseUrl: '',
11 | http: api
12 | });
13 |
14 | const oldModelSave = Model.prototype.save;
15 |
16 | Model.prototype.save = function() {
17 |
18 | return oldModelSave.apply(this, arguments).then(model => {
19 | publish('resourceSaved', model);
20 | return model;
21 | });
22 |
23 | };
24 |
25 | Model.getTypeUrlSegment = type => bootData('resourceToApiMap.' + type) || type;
26 |
27 | export {Model, Collection};
28 |
--------------------------------------------------------------------------------
/src/font/icons/Read Me.txt:
--------------------------------------------------------------------------------
1 | Open *demo.html* to see a list of all the glyphs in your font along with their codes/ligatures.
2 |
3 | To use the generated font in desktop programs, you can install the TTF font. In order to copy the character associated with each icon, refer to the text box at the bottom right corner of each glyph in demo.html. The character inside this text box may be invisible; but it can still be copied. See this guide for more info: https://icomoon.io/#docs/local-fonts
4 |
5 | You won't need any of the files located under the *demo-files* directory when including the generated font in your own projects.
6 |
7 | You can import *selection.json* back to the IcoMoon app using the *Import Icons* button (or via Main Menu → Manage Projects) to retrieve your icon selection.
8 |
--------------------------------------------------------------------------------
/src/js/mixins/emitter.js:
--------------------------------------------------------------------------------
1 | import mitt from 'mitt';
2 |
3 | export default {
4 |
5 | beforeCreate() {
6 | const emitter = mitt();
7 | this.$emitter = {
8 | emit: emitter.emit.bind(emitter),
9 | on: emitter.on.bind(emitter),
10 | off: emitter.off.bind(emitter),
11 | once: (type, handler) => {
12 | const fn = (...args) => {
13 | emitter.off(type, fn);
14 | handler(args);
15 | };
16 | emitter.on(type, fn);
17 | },
18 | clear: () => {
19 | emitter.all.clear();
20 | }
21 | };
22 | },
23 |
24 | unmounted() {
25 | this.$emitter.clear();
26 | delete this.$emitter;
27 | }
28 |
29 | };
30 |
--------------------------------------------------------------------------------
/src/scss/library/helpers/_normalizePlusReset.scss:
--------------------------------------------------------------------------------
1 | // Normalize & Reset
2 |
3 | @mixin normalizePlusReset {
4 |
5 | a {
6 | text-decoration: none; cursor: pointer; color: inherit;
7 | }
8 |
9 | a:focus,
10 | a::-moz-focus-inner {
11 | outline: none;
12 | }
13 |
14 | ol, ul, li, h1, h2, h3, h4, p, a, td, dl, dt, dd, fieldset {
15 | margin: 0; padding: 0; list-style: none; border: 0; font-size: 1em;
16 | }
17 |
18 | table {
19 | border-collapse: collapse; border-spacing: 0;
20 | }
21 |
22 | img {
23 | vertical-align:bottom;
24 | }
25 |
26 | button,
27 | input,
28 | select,
29 | textarea {
30 | font-family: inherit; font-size: 100%; margin: 0; outline: none; box-sizing: border-box; border-radius: 0;
31 | }
32 |
33 | textarea {
34 | overflow: auto; vertical-align: top; resize:vertical;
35 | }
36 |
37 | label, button {
38 | cursor: pointer;
39 | }
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/demo/auth.js:
--------------------------------------------------------------------------------
1 | import api from 'trim/js/library/api.js';
2 | import auth from 'trim/js/library/auth.js';
3 |
4 | export default auth.extend({
5 |
6 | isUserLogged() {
7 |
8 | return Boolean(localStorage.getItem('accessToken'));
9 |
10 | },
11 |
12 | loginWithCredentials(credentials) {
13 |
14 | return new Promise((resolve, reject) => {
15 |
16 | if (credentials.username.length && credentials.password.length) {
17 | localStorage.setItem('accessToken', 'testToken');
18 | resolve();
19 | } else {
20 | reject(new Error('Login failed'));
21 | }
22 |
23 | });
24 |
25 | },
26 |
27 | onAuthorization() {
28 |
29 | api.defaults.headers.common.Authorization = 'Bearer ' + localStorage.getItem('accessToken');
30 |
31 | },
32 |
33 | onDeauthorization() {
34 |
35 | localStorage.removeItem('accessToken');
36 | delete api.defaults.headers.common.Authorization;
37 |
38 | }
39 |
40 | });
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2018 Trikoder
2 |
3 | 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:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | 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.
--------------------------------------------------------------------------------
/server/node.js:
--------------------------------------------------------------------------------
1 | import ExpressServer from 'json-api-shop/servers/express.js';
2 | import MemoryAdapter from 'json-api-shop/adapters/memory.js';
3 | import fkill from 'fkill';
4 | import resources from './resources.js';
5 |
6 | const Server = ExpressServer.extend({
7 |
8 | setupRoutes(app) {
9 |
10 | app.post('/media/upload', (request, response) => {
11 | response.set('Content-Type', 'application/javascript');
12 | response.set('Access-Control-Expose-Headers', 'Location');
13 | response.set('Location', process.env.BASE_API_URL + 'media/1');
14 | response.send('');
15 | });
16 |
17 | return ExpressServer.prototype.setupRoutes.call(this, app); ;
18 |
19 | }
20 |
21 | });
22 |
23 | ;(async() => {
24 | try {
25 | await fkill(`:${process.env.API_PORT}`);
26 | } catch {
27 | // Handled
28 | }
29 | new Server({
30 | port: process.env.API_PORT,
31 | databaseAdapter: MemoryAdapter,
32 | resources
33 | }).start();
34 | })();
35 |
--------------------------------------------------------------------------------
/src/js/library/userPreferences.js:
--------------------------------------------------------------------------------
1 | import {each} from './toolkit.js';
2 |
3 | const storageKey = 'userPreferences';
4 | const storageData = localStorage.getItem(storageKey);
5 | const preferenceData = storageData ? JSON.parse(storageData) : {};
6 |
7 | export default {
8 |
9 | get(key, defaultValue) {
10 |
11 | const data = preferenceData[key];
12 | return typeof data !== 'undefined' ? data : defaultValue;
13 |
14 | },
15 |
16 | set(key, value) {
17 |
18 | each(arguments.length === 1
19 | ? key
20 | : {
21 | [key]: value
22 | }, (paramValue, paramKey) => {
23 | preferenceData[paramKey] = paramValue;
24 | });
25 |
26 | localStorage.setItem(storageKey, JSON.stringify(preferenceData));
27 |
28 | return this;
29 |
30 | },
31 |
32 | unset(key) {
33 |
34 | delete preferenceData[key];
35 | localStorage.setItem(storageKey, JSON.stringify(preferenceData));
36 |
37 | return this;
38 |
39 | }
40 |
41 | };
42 |
--------------------------------------------------------------------------------
/src/js/library/viewport.js:
--------------------------------------------------------------------------------
1 | let currentBreakpoint;
2 | let config;
3 |
4 | function checkBreakpoints() {
5 |
6 | const windowWidth = 'innerWidth' in window
7 | ? window.innerWidth
8 | : document.documentElement.offsetWidth
9 | ;
10 |
11 | for (const key in config.ranges) {
12 |
13 | const range = config.ranges[key];
14 | const from = range[0];
15 | const to = range[1];
16 |
17 | if (windowWidth >= from && windowWidth <= to && currentBreakpoint !== key) {
18 | currentBreakpoint = key;
19 | config.onMatch(key);
20 | }
21 |
22 | }
23 |
24 | }
25 |
26 | export default {
27 |
28 | setBreakpoints(userConfig) {
29 |
30 | config = userConfig;
31 |
32 | window.removeEventListener('resize', checkBreakpoints);
33 | window.addEventListener('resize', checkBreakpoints);
34 |
35 | return this;
36 |
37 | },
38 |
39 | checkBreakpoints() {
40 |
41 | checkBreakpoints();
42 | return this;
43 |
44 | }
45 |
46 | };
47 |
--------------------------------------------------------------------------------
/src/js/listElements/date.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
33 |
34 |
52 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - master
6 | jobs:
7 | Documentation:
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | node-version:
16 | - 18
17 | environment:
18 | name: github-pages
19 | url: ${{ steps.deployment.outputs.page_url }}
20 | steps:
21 | - name: Clone repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Use Node ${{ matrix.node-version }}
25 | uses: actions/setup-node@v4
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 |
29 | - name: Install dependencies
30 | run: npm ci
31 |
32 | - name: Build documentation
33 | run: npm run build
34 |
35 | - name: Deploy documentation
36 | uses: actions/upload-pages-artifact@v3
37 | with:
38 | path: ./docs
39 |
40 | - name: Deploy to GitHub Pages
41 | uses: actions/deploy-pages@v4
42 | id: deployment
43 |
--------------------------------------------------------------------------------
/src/font/icons/demo-files/demo.js:
--------------------------------------------------------------------------------
1 | if (!('boxShadow' in document.body.style)) {
2 | document.body.setAttribute('class', 'noBoxShadow');
3 | }
4 |
5 | document.body.addEventListener('click', function(e) {
6 | const target = e.target;
7 | if (target.tagName === 'INPUT' &&
8 | target.getAttribute('class').indexOf('liga') === -1) {
9 | target.select();
10 | }
11 | });
12 |
13 | (function() {
14 | const fontSize = document.getElementById('fontSize');
15 | const testDrive = document.getElementById('testDrive');
16 | const testText = document.getElementById('testText');
17 | function updateTest() {
18 | testDrive.innerHTML = testText.value || String.fromCharCode(160);
19 | if (window.icomoonLiga) {
20 | window.icomoonLiga(testDrive);
21 | }
22 | }
23 | function updateSize() {
24 | testDrive.style.fontSize = fontSize.value + 'px';
25 | }
26 | fontSize.addEventListener('change', updateSize, false);
27 | testText.addEventListener('input', updateTest, false);
28 | testText.addEventListener('change', updateTest, false);
29 | updateSize();
30 | }());
31 |
--------------------------------------------------------------------------------
/src/js/formElements/number.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
48 |
--------------------------------------------------------------------------------
/src/scss/partials/_animations.scss:
--------------------------------------------------------------------------------
1 | @keyframes fadeIn {
2 | from { opacity: 0; }
3 | to { opacity: 1; }
4 | }
5 |
6 | @keyframes slideDown {
7 | from { transform: translate3d(0, -1em, 0); }
8 | to { transform: translate3d(0, 0, 0); }
9 | }
10 |
11 | @keyframes slideDownFadeIn {
12 | from { opacity: 0; transform: translate3d(0, -1em, 0); }
13 | to { opacity: 1; transform: translate3d(0, 0, 0); }
14 | }
15 |
16 | @keyframes slideUp {
17 | from { transform: translate3d(0, 1em, 0); }
18 | to { transform: translate3d(0, 0, 0); }
19 | }
20 |
21 | @keyframes slideUpFadeIn {
22 | from { opacity: 0; transform: translate3d(0, 1em, 0); }
23 | to { opacity: 1; transform: translate3d(0, 0, 0); }
24 | }
25 |
26 | @keyframes slideFromRightFadeIn {
27 | from { opacity: 0; transform: translate3d(4em, 0, 0); }
28 | to { opacity: 1; transform: translate3d(0, 0, 0); }
29 | }
30 |
31 | @keyframes slideFromRight {
32 | from { transform: translate3d(4em, 0, 0); }
33 | to { transform: translate3d(0, 0, 0); }
34 | }
35 |
36 | @keyframes rotate {
37 | from { transform: rotate(0deg); }
38 | to { transform: rotate(360deg); }
39 | }
40 |
--------------------------------------------------------------------------------
/src/js/library/loadControllerType.js:
--------------------------------------------------------------------------------
1 | import serviceContainer from './serviceContainer.js';
2 | import Loader from './loader.js';
3 |
4 | const controllerPromiseMap = {};
5 |
6 | export default function(controllerName) {
7 |
8 | if (controllerPromiseMap[controllerName]) {
9 | return controllerPromiseMap[controllerName];
10 | }
11 |
12 | const loader = Loader.on();
13 |
14 | controllerPromiseMap[controllerName] = serviceContainer.get(
15 | controllerName + 'Controller'
16 | ).then(definition => {
17 |
18 | if (definition.extendsController || definition.resourceName) {
19 |
20 | const controllerName = (definition.extendsController || 'BaseResource') + 'Controller';
21 |
22 | return serviceContainer
23 | .get(controllerName)
24 | .then(BaseController => {
25 | loader.off();
26 | return BaseController.toVueComponent(definition);
27 | });
28 |
29 | } else {
30 | loader.off();
31 | return definition;
32 | }
33 |
34 | });
35 |
36 | return controllerPromiseMap[controllerName];
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/documentation/getting-started.md:
--------------------------------------------------------------------------------
1 | # Getting started
2 | Before digging into UI code make sure you have a basic understanding of core JSON:API concepts (how relations, attributes, getting, creating and updating resources work). A functional backend API compliant with JSON:API standard is prerequisite for building UI.
3 | Browse [JSON:API webpage](http://jsonapi.org/) and [examples](http://jsonapi.org/examples/) to familiarize yourself with standard.
4 |
5 | Everything explained in this chapter has concrete implementation details in demo application codebase.
6 | Feel free to [browse demo codebase](https://github.com/trikoder/trim/tree/master/demo) and take what you need.
7 |
8 | ## Starter template
9 | Starter template for bootstrapping projects built with Trikoder Trim is available at [trim-starter git repository](https://github.com/trikoder/trim-starter/).
10 |
11 | ```bash
12 | git clone git@github.com:trikoder/trim-starter.git my-cms-project
13 | ```
14 |
15 | Make sure you have Node.js (8.x and up) and NPM installed.
16 |
17 | ```bash
18 | cd my-cms-project
19 | npm install
20 | ```
21 |
22 | Once modules are installed run dev command.
23 | ```sh
24 | npm run dev
25 | ```
26 |
27 | Your new CMS project should greet you now with welcome page running at localhost.
28 |
--------------------------------------------------------------------------------
/src/js/components/appView.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
13 |
14 |
50 |
--------------------------------------------------------------------------------
/src/js/formElements/dateTime.vue:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/src/scss/library/_projectMixins.scss:
--------------------------------------------------------------------------------
1 | //--------------------------------------------------------------
2 | // Fonts
3 | //--------------------------------------------------------------
4 | @mixin fontSans { font-family: Roboto, sans-serif; font-weight: 400; font-style: normal; }
5 | @mixin fontSansBold { font-family: Roboto, sans-serif; font-weight: 700; font-style: normal; }
6 | @mixin fontSansItalic { font-family: Roboto, sans-serif; font-weight: 400; font-style: italic; }
7 | @mixin fontSansBoldItalic { font-family: Roboto, sans-serif; font-weight: 700; font-style: italic; }
8 |
9 | @mixin fontSansCondensed { font-family: 'Archivo Narrow', sans-serif; font-weight: 400; font-style: normal; }
10 | @mixin fontSansCondensedBold { font-family: 'Archivo Narrow', sans-serif; font-weight: 700; font-style: normal; }
11 | @mixin fontSansCondensedItalic { font-family: 'Archivo Narrow', sans-serif; font-weight: 400; font-style: italic; }
12 | @mixin fontSansCondensedBoldItalic { font-family: 'Archivo Narrow', sans-serif; font-weight: 700; font-style: italic; }
13 |
14 | @mixin ellipsis($width: 100%) {
15 | max-width: $width; display: inline-block; overflow: hidden;
16 | text-overflow: ellipsis; white-space: nowrap; word-wrap: normal;
17 | }
18 |
19 | @mixin clearfix {
20 | &::after {
21 | clear: both; content: ""; display: table;
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/src/js/library/serviceContainer.js:
--------------------------------------------------------------------------------
1 | import {each} from './toolkit.js';
2 |
3 | const registry = {};
4 |
5 | function getOne(serviceName, options) {
6 |
7 | if (typeof registry[serviceName] === 'undefined') {
8 | throw new Error('Service "' + serviceName + '" is not defined');
9 | }
10 |
11 | const service = Promise.resolve(registry[serviceName]());
12 |
13 | return options && options.fullExport
14 | ? service
15 | : service.then(
16 | importedModule => isEsModule(importedModule) ? importedModule.default : importedModule
17 | );
18 |
19 | }
20 |
21 | function isEsModule(_module) {
22 | return _module.__esModule || _module[Symbol.toStringTag] === 'Module';
23 | }
24 |
25 | export default {
26 |
27 | get: function(serviceName, options) {
28 |
29 | return Array.isArray(serviceName)
30 | ? Promise.all(serviceName.map(singleService => getOne(singleService, options)))
31 | : getOne(serviceName, options);
32 |
33 | },
34 |
35 | register: function(serviceName, handler) {
36 |
37 | if (handler) {
38 | registry[serviceName] = handler;
39 | } else {
40 | each(serviceName, function(serviceHandler, serviceKey) {
41 | registry[serviceKey] = serviceHandler;
42 | });
43 | }
44 |
45 | }
46 |
47 | };
48 |
--------------------------------------------------------------------------------
/src/js/store.js:
--------------------------------------------------------------------------------
1 | import {createStore} from 'vuex';
2 |
3 | export default createStore({
4 |
5 | state: {
6 | breakpoint: undefined,
7 | selectedNavKey: undefined,
8 | projectCaption: undefined,
9 | breadcrumbs: undefined,
10 | loading: false,
11 | popupLevel: 0
12 | },
13 |
14 | mutations: {
15 |
16 | setBreakpoint(state, breakpoint) {
17 | state.breakpoint = breakpoint;
18 | },
19 |
20 | setNavSelected(state, key) {
21 | state.selectedNavKey = key;
22 | },
23 |
24 | setProjectCaption(state, projectCaption) {
25 | state.projectCaption = projectCaption;
26 | },
27 |
28 | setBreadcrumbs(state, breadcrumbs) {
29 | state.breadcrumbs = breadcrumbs;
30 | },
31 |
32 | loading(state, isLoading) {
33 | state.loading = Boolean(isLoading);
34 | },
35 |
36 | addPopup(state) {
37 |
38 | const maxLevel = 2;
39 | const currentLevel = state.popupLevel;
40 |
41 | state.popupLevel = currentLevel + 1 > maxLevel ? maxLevel : currentLevel + 1;
42 |
43 | },
44 |
45 | removePopup(state) {
46 |
47 | if (state.popupLevel) {
48 | state.popupLevel--;
49 | }
50 |
51 | }
52 |
53 | },
54 |
55 | actions: {}
56 |
57 | });
58 |
--------------------------------------------------------------------------------
/demo/main.js:
--------------------------------------------------------------------------------
1 | import app from 'trim/js/app.js';
2 | import translations from 'trim/js/lang/english.js';
3 | import routes from './routes.js';
4 | import services from './services.js';
5 | import DummyComponent from './dummy.vue';
6 | // import auth from './auth';
7 | import 'apiServer';
8 |
9 | import './scss/main.scss';
10 |
11 | app
12 | .setBootData({
13 | // usePatchForUpdate: false,
14 | // apiPagination: {
15 | // strategy: 'offsetBased',
16 | // offsetKey: 'offset',
17 | // limitKey: 'limit',
18 | // limitOptions: [50, 100, 200, 300, 500],
19 | // itemsPerPage: 100
20 | // }
21 | // resourceToApiMap: {
22 | // article: 'article'
23 | // user: 'user'
24 | // }
25 | toggleColumnsVisibility: true,
26 | validationErrorField: 'detail',
27 | itemsPerPage: process.env.NODE_ENV !== 'production' ? 15 : 10,
28 | usesPushState: process.env.NODE_ENV !== 'production',
29 | googleMapsApiKey: 'AIzaSyBVqg9EqOqARXVIaKRSC7pJpVeHKDRoU2I',
30 | baseUrl: process.env.BASE_URL,
31 | baseApiUrl: process.env.BASE_API_URL
32 | })
33 | .loadTranslations(translations, 'en')
34 | .registerServices(services)
35 | .registerRoutes(routes)
36 | .beforeAdminEnter(() => {})
37 | .appendAppComponent(DummyComponent)
38 | // .useAuth(auth)
39 | .start();
40 |
--------------------------------------------------------------------------------
/src/js/components/resourceEdit/group.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
15 |
16 |
56 |
--------------------------------------------------------------------------------
/demo/controllers/tag.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | resourceName: 'tag',
4 |
5 | setupList: function({list}) {
6 |
7 | this.addCreateControl('Create new tag');
8 |
9 | // --------------------------------------------------------------
10 | // Filters
11 | // --------------------------------------------------------------
12 | // list.addFilter('TextFormElement', {
13 | // name: 'title',
14 | // label: 'Title'
15 | // });
16 |
17 | // --------------------------------------------------------------
18 | // List items
19 | // --------------------------------------------------------------
20 | list.addItem('TextListItem', {
21 | caption: 'ID',
22 | mapTo: 'id',
23 | addIf: this.screenIsLarge
24 | });
25 |
26 | list.addItem('LinkListItem', {
27 | caption: 'Title',
28 | mapTo: 'title',
29 | action: 'editItem'
30 | });
31 |
32 | list.addItem('ContextMenuListItem', {
33 | caption: 'Actions',
34 | items: [{caption: 'Edit', action: 'editItem'}]
35 | });
36 |
37 | },
38 |
39 | setupEdit: function({edit}) {
40 |
41 | this.addToIndexControl().addSaveControl();
42 |
43 | edit.addField('TextFormElement', {
44 | label: 'Title',
45 | name: 'title',
46 | attributes: {input: {class: 'inputType2 size2'}}
47 | });
48 |
49 | }
50 |
51 | };
52 |
--------------------------------------------------------------------------------
/src/js/formElements/color.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 |
11 |
12 |
42 |
43 |
63 |
--------------------------------------------------------------------------------
/src/js/formElements/htmlComponents/baseComponent.vue:
--------------------------------------------------------------------------------
1 |
66 |
67 |
--------------------------------------------------------------------------------
/src/js/library/getVueComponentMapper.js:
--------------------------------------------------------------------------------
1 | import {each, assignDeep, omit} from '../library/toolkit.js';
2 |
3 | const vueOptions = [
4 | 'props', 'computed', 'watch',
5 | 'template', 'render', 'renderError',
6 | 'beforeCreate', 'created', 'beforeMount', 'mounted',
7 | 'beforeUpdate', 'updated', 'activated', 'deactivated',
8 | 'beforeUnmount', 'unmounted', 'errorCaptured'
9 | ];
10 |
11 | const deprecatedDataOptions = {
12 | includeApiData: 'includedRelationships'
13 | };
14 |
15 | export default function(params) {
16 |
17 | return function(blueprint) {
18 |
19 | const Parent = params.extends;
20 | const vueObject = {};
21 | const data = {};
22 | const methods = {};
23 |
24 | each(blueprint, (value, key) => {
25 | if (deprecatedDataOptions[key]) {
26 | data[deprecatedDataOptions[key]] = value;
27 | } else if (vueOptions.indexOf(key) >= 0) {
28 | vueObject[key] = value;
29 | } else if (params.dataKeys.indexOf(key) >= 0) {
30 | data[key] = value;
31 | } else if (typeof value === 'function') {
32 | methods[key] = value;
33 | }
34 | });
35 |
36 | return {
37 | extends: omit(Parent, 'data'),
38 | mixins: [vueObject],
39 | data() {
40 | const parentData = Parent.data ? Parent.data.call(this) : {};
41 | return assignDeep({}, parentData, data);
42 | },
43 | methods
44 | };
45 |
46 | };
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/src/js/components/resourceEdit/region.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
24 |
25 |
26 |
27 |
28 |
29 |
56 |
--------------------------------------------------------------------------------
/src/js/listElements/text.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
44 |
45 |
71 |
--------------------------------------------------------------------------------
/src/js/listElements/dateTime.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
54 |
55 |
73 |
--------------------------------------------------------------------------------
/src/js/formElements/htmlComponents/componentControls.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
37 |
38 |
65 |
66 |
--------------------------------------------------------------------------------
/src/js/listElements/blip.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
58 |
59 |
83 |
--------------------------------------------------------------------------------
/src/js/library/loader.js:
--------------------------------------------------------------------------------
1 | import {assign} from './toolkit.js';
2 |
3 | let actions = null;
4 | let timeouts = [];
5 | let loaderActive = false;
6 |
7 | function Loader() {
8 |
9 | if (!(this instanceof Loader)) {
10 | return new Loader();
11 | }
12 |
13 | }
14 |
15 | function activate() {
16 |
17 | if (!loaderActive) {
18 | loaderActive = true;
19 | actions.onActivate();
20 | }
21 |
22 | }
23 |
24 | function deactivate() {
25 |
26 | if (loaderActive) {
27 | loaderActive = false;
28 | actions.onDeactivate();
29 | }
30 |
31 | }
32 |
33 | assign(Loader.prototype, {
34 |
35 | on() {
36 |
37 | if (actions) {
38 |
39 | this.off();
40 | this.timeout = setTimeout(activate, 50);
41 | timeouts.push(this.timeout);
42 |
43 | }
44 |
45 | return this;
46 |
47 | },
48 |
49 | off() {
50 |
51 | if (actions && this.timeout) {
52 |
53 | clearTimeout(this.timeout);
54 | timeouts = timeouts.filter(timeout => timeout !== this.timeout);
55 | delete this.timeout;
56 |
57 | if (timeouts.length === 0) {
58 | deactivate();
59 | }
60 |
61 | }
62 |
63 | return this;
64 |
65 | }
66 | });
67 |
68 | assign(Loader, {
69 |
70 | on() {
71 |
72 | return new Loader().on();
73 |
74 | },
75 |
76 | off() {
77 |
78 | if (actions) {
79 |
80 | timeouts.forEach(timeout => clearTimeout(timeout));
81 | timeouts = [];
82 |
83 | deactivate();
84 |
85 | }
86 |
87 | return this;
88 |
89 | },
90 |
91 | setActions(userActions) {
92 | actions = userActions;
93 | return this;
94 | }
95 |
96 | });
97 |
98 | export default Loader;
99 |
--------------------------------------------------------------------------------
/src/js/controllers/error.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
{{title}}
6 |
{{message}}
7 |
8 |
9 |
10 |
11 |
49 |
50 |
82 |
--------------------------------------------------------------------------------
/src/js/library/browserFeatures.js:
--------------------------------------------------------------------------------
1 | const browserFeatures = {};
2 | const html = document.getElementsByTagName('html')[0];
3 |
4 | function setBrowserFeature(feature, supported) {
5 |
6 | browserFeatures[feature] = supported;
7 | html.className += (supported ? ' ' : ' no-') + feature;
8 |
9 | }
10 |
11 | function prefixedProperty(property) {
12 |
13 | const style = document.createElement('div').style;
14 | const prefixes = ['Webkit', 'Moz', 'ms', 'O'];
15 | const upperCaseProperty = property.charAt(0).toUpperCase() + property.slice(1);
16 | let actualProp = false;
17 |
18 | if (property in style) {
19 | return property;
20 | }
21 |
22 | for (let i = 0; i < prefixes.length; i++) {
23 |
24 | const possibleProp = prefixes[i] + upperCaseProperty;
25 |
26 | if (possibleProp in style) {
27 |
28 | actualProp = possibleProp;
29 | break;
30 |
31 | }
32 | }
33 |
34 | return actualProp;
35 |
36 | }
37 |
38 | browserFeatures.runTests = function() {
39 |
40 | if (typeof window.global === 'undefined') {
41 | window.global = window;
42 | }
43 |
44 | setBrowserFeature('svg', Boolean('createElementNS' in document && document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGRect));
45 |
46 | setBrowserFeature('csstransforms', Boolean(prefixedProperty('transform')));
47 |
48 | setBrowserFeature('csstransitions', Boolean(prefixedProperty('transition')));
49 |
50 | setBrowserFeature('touch', Boolean('ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch));
51 |
52 | setBrowserFeature('history', Boolean(window.history && window.history.pushState));
53 |
54 | return this;
55 |
56 | };
57 |
58 | browserFeatures.prefixed = prefixedProperty;
59 | browserFeatures.setFeature = setBrowserFeature;
60 |
61 | export default browserFeatures;
62 |
--------------------------------------------------------------------------------
/src/js/components/resourceEdit/tab.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
22 |
32 |
33 |
34 |
35 |
36 |
64 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import {promises as fs} from 'fs';
2 | import {defineConfig} from 'vite';
3 | import vue from '@vitejs/plugin-vue';
4 | const isProduction = process.env.NODE_ENV === 'production';
5 | const currentVersion = JSON.parse(await fs.readFile(new URL('./package.json', import.meta.url), 'utf8')).version;
6 |
7 | export default defineConfig({
8 | root: 'demo',
9 | plugins: [
10 | vue()
11 | ],
12 | build: {
13 | emptyOutDir: true,
14 | outDir: isProduction
15 | ? new URL('docs/demo', import.meta.url).pathname
16 | : new URL('dist', import.meta.url).pathname
17 | },
18 | base: isProduction ? '/trim/demo/' : '/',
19 | resolve: {
20 | alias: {
21 | demo: new URL('demo', import.meta.url).pathname,
22 | trim: new URL('src', import.meta.url).pathname,
23 | apiServer: new URL(process.env.CLIENT_API_ENABLED
24 | ? 'server/client.js'
25 | : 'server/nodePlaceholder.js'
26 | , import.meta.url).pathname
27 | }
28 | },
29 | define: {
30 | 'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
31 | 'process.env.BASE_API_URL': JSON.stringify(process.env.BASE_API_URL),
32 | 'process.env.BASE_ABSOLUTE_URL': JSON.stringify(process.env.CI ? 'https://trikoder.github.io' : 'http://localhost:3000'),
33 | 'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
34 | 'process.env.PACKAGE_VERSION': JSON.stringify(currentVersion),
35 | __VUE_OPTIONS_API__: JSON.stringify(true),
36 | __VUE_PROD_DEVTOOLS__: JSON.stringify(false)
37 | },
38 | css: {
39 | preprocessorOptions: {
40 | scss: {
41 | api: 'modern',
42 | additionalData: `
43 | @use 'demo/scss/variables';
44 | `
45 | }
46 | }
47 | }
48 | });
49 |
--------------------------------------------------------------------------------
/documentation/.vitepress/config.js:
--------------------------------------------------------------------------------
1 | const baseAbsoluteUrl = process.env.CI ? 'https://trikoder.github.io' : 'http://localhost:3000';
2 |
3 | export default {
4 | outDir: '../docs',
5 | title: 'Trikoder Trim',
6 | description: 'Responsive UI framework for building content management systems with simple authoring API. Designed to run as a browser application connected to JSON:API powered backend.',
7 | base: '/trim/',
8 | head: [
9 | ['link', {rel: 'shortcut icon', href: `/favicon.ico`}],
10 | ['link', {rel: 'apple-touch-icon-precomposed', sizes: '152x152', href: '/apple-touch-icon-152x152.png'}],
11 | ['link', {rel: 'icon', sizes: '196x196', href: '/favicon-196x196.png'}]
12 | ],
13 | themeConfig: {
14 | nav: [
15 | { text: 'Documentation', link: '/about' },
16 | { text: 'Demo', link: baseAbsoluteUrl + '/trim/demo/index.html' },
17 | { text: 'GitHub', link: 'https://github.com/trikoder/trim' }
18 | ],
19 | sidebar: [
20 | {text: 'Home', link: '/'},
21 | {text: 'About', link: '/about'},
22 | {text: 'Getting started', link: '/getting-started'},
23 | {text: 'Core concepts and API', link: '/core-concepts-and-api'},
24 | {text: 'Adding resource', link: '/adding-resource'},
25 | {text: 'Form elements', link: '/form-elements'},
26 | {text: 'List elements', link: '/list-elements'},
27 | {text: 'Base controllers', link: '/base-controllers'},
28 | {text: 'Changelog', link: '/changelog'}
29 | ]
30 | },
31 | appearance: false,
32 | markdown: {
33 | theme: 'github-dark'
34 | },
35 | vite: {
36 | define: {
37 | // Probably related to https://github.com/vuejs/vitepress/commit/9794877347140c7b4955d735cd8867c260a5089d
38 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
39 | }
40 | }
41 | };
42 |
--------------------------------------------------------------------------------
/src/js/library/ckeditor.js:
--------------------------------------------------------------------------------
1 | import littleLoader from 'little-loader';
2 | import bootData from '../library/bootData.js';
3 | import app from '../app.js';
4 |
5 | const ckeditorPath = new URL('ckeditor4/ckeditor.js', import.meta.url);
6 | let ckeditorImport = null;
7 | function importCkEditor() {
8 | if (ckeditorImport) {
9 | return ckeditorImport;
10 | }
11 | ckeditorImport = new Promise((resolve, reject) => {
12 | littleLoader(ckeditorPath.href, (error) => {
13 | if (error) {
14 | reject(error);
15 | } else {
16 | resolve();
17 | }
18 | });
19 | });
20 | return ckeditorImport;
21 | }
22 |
23 | export function load() {
24 |
25 | window.CKEDITOR_BASEPATH = bootData(
26 | 'ckEditorPath',
27 | 'https://cdn.ckeditor.com/4.11.4/standard-all/'
28 | );
29 |
30 | return importCkEditor().then(() => {
31 |
32 | const locale = app.getLocale();
33 | const ckeditor = window.CKEDITOR;
34 |
35 | ckeditor.disableAutoInline = true;
36 | ckeditor.config.defaultLanguage = locale;
37 | ckeditor.config.language = locale;
38 | ckeditor.config.extraPlugins = 'sourcedialog';
39 | ckeditor.config.entities = false;
40 |
41 | ckeditor.customStyles = {
42 | richCombo: `
43 | .body, html { margin: 0; padding: 0; }
44 | .cke_panel_block { padding: 5px 0; outline: none !important; }
45 | .cke_panel_list { list-style:none; padding: 0; margin: 0; }
46 | .cke_panel_listItem { padding: 0; margin; 0; }
47 | .cke_panel_listItem a { font-family: Arial; display:block; padding: 5px 10px; text-decoration:none; font-size: 13px; color: #303030; }
48 | .cke_panel_listItem a:hover { background-color: #ededed; }
49 | `
50 | };
51 |
52 | return ckeditor;
53 |
54 | });
55 |
56 | };
57 |
--------------------------------------------------------------------------------
/src/js/formElements/textarea.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ modelValue }}
4 |
9 |
10 |
11 |
12 |
58 |
59 |
90 |
--------------------------------------------------------------------------------
/src/js/controllers/baseNestedResource.vue:
--------------------------------------------------------------------------------
1 |
76 |
--------------------------------------------------------------------------------
/demo/controllers/article/quoteInContent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
16 |
17 |
18 |
19 |
20 |
52 |
53 |
80 |
--------------------------------------------------------------------------------
/demo/controllers/snippet.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | resourceName: 'snippet',
4 | resourceCaption: 'title',
5 |
6 | setupList: function({list}) {
7 |
8 | this.addCreateControl('Create new snippet');
9 |
10 | // --------------------------------------------------------------
11 | // Filters
12 | // --------------------------------------------------------------
13 | list.addFilter('TextFormElement', {
14 | name: 'title',
15 | label: 'Title'
16 | });
17 |
18 | // --------------------------------------------------------------
19 | // List items
20 | // --------------------------------------------------------------
21 | list.addItem('TextListItem', {
22 | caption: 'ID',
23 | mapTo: 'id',
24 | addIf: this.screenIsLarge
25 | });
26 |
27 | list.addItem('LinkListItem', {
28 | caption: 'Title',
29 | mapTo: 'title',
30 | action: 'editItem'
31 | });
32 |
33 | list.addItem('TextListItem', {
34 | caption: 'Code',
35 | mapTo: 'code'
36 | });
37 |
38 | list.addItem('TextListItem', {
39 | caption: 'Content',
40 | mapTo: 'content',
41 | limitCharacters: 130,
42 | stripTags: true
43 | });
44 |
45 | list.addItem('ContextMenuListItem', {
46 | caption: 'Actions',
47 | items: [{caption: 'Edit', action: 'editItem'}]
48 | });
49 |
50 | },
51 |
52 | setupEdit: function({edit}) {
53 |
54 | this.addToIndexControl().addSaveControl();
55 |
56 | edit.addField('TextareaFormElement', {
57 | label: 'Title',
58 | name: 'title',
59 | attributes: {input: {class: 'inputType2 size2 fontBold'}}
60 | });
61 |
62 | edit.addField('TextFormElement', {
63 | label: 'Code',
64 | name: 'code'
65 | });
66 |
67 | edit.addField('CodeFormElement', {
68 | label: 'Content',
69 | name: 'content'
70 | });
71 |
72 | }
73 |
74 | };
75 |
--------------------------------------------------------------------------------
/src/js/mixins/headerBorderListener.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | data: () => ({headerHasBorder: false}),
4 |
5 | created() {
6 |
7 | this.setupHeaderBorderListener();
8 |
9 | },
10 |
11 | beforeUnmount() {
12 |
13 | this.removeHeaderScrollListener();
14 |
15 | },
16 |
17 | methods: {
18 |
19 | setupHeaderBorderListener() {
20 |
21 | ['afterIndexSetup', 'afterEditSetup', 'afterCreateSetup'].forEach(event => {
22 | if ('$emitter' in this) {
23 | this.$emitter.on(event, () => {
24 | this.$nextTick(() => this.addHeaderScrollListener());
25 | });
26 | }
27 | });
28 |
29 | },
30 |
31 | addHeaderScrollListener() {
32 |
33 | this.removeHeaderScrollListener();
34 |
35 | const useFixedBorder = this.currentContext === 'index'
36 | ? (this.$refs.listHandler ? !this.$refs.listHandler.showFilters : true)
37 | : (this.$refs.editHandler ? this.$refs.editHandler.tabItems.length === 0 : true)
38 | ;
39 |
40 | if (useFixedBorder) {
41 | this.headerHasBorder = true;
42 | return;
43 | }
44 |
45 | this.headerScrollElement = this.isExternal
46 | ? this.$refs[this.currentContext === 'index' ? 'listHandler' : 'editHandler'].$el
47 | : window
48 | ;
49 |
50 | this.headerScrollListener = () => {
51 |
52 | this.headerHasBorder = this.headerScrollElement === window
53 | ? window.pageYOffset > 0
54 | : this.headerScrollElement.scrollTop > 0
55 | ;
56 |
57 | };
58 |
59 | this.headerScrollElement.addEventListener('scroll', this.headerScrollListener);
60 | this.headerScrollListener();
61 |
62 | },
63 |
64 | removeHeaderScrollListener() {
65 |
66 | if (this.headerScrollElement && this.headerScrollListener) {
67 | this.headerScrollElement.removeEventListener('scroll', this.headerScrollListener);
68 | delete this.headerScrollElement;
69 | delete this.headerScrollListener;
70 | }
71 |
72 | }
73 |
74 | }
75 |
76 | };
77 |
--------------------------------------------------------------------------------
/src/scss/partials/_core.scss:
--------------------------------------------------------------------------------
1 | @use '../library/all' as *;
2 |
3 | @include normalizePlusReset();
4 | @include pageEmEquivalent(10);
5 |
6 | //--------------------------------------------------------------
7 | // Main layout
8 | //--------------------------------------------------------------
9 | html,
10 | body {
11 |
12 | min-height: 100%;
13 |
14 | @include mediaMinWidth($breakpointMedium) {
15 | background-color: $colorPageBackground;
16 | }
17 |
18 | }
19 |
20 | a {
21 | color: $colorMain1;
22 | }
23 |
24 | body {
25 |
26 | @include fontSansCondensed;
27 | min-width: 32em; position: relative;
28 | color: $colorGrayDark1;
29 | overflow-x: hidden;
30 |
31 | }
32 |
33 | .appContainer {
34 |
35 | position: relative; min-height: 100vh; box-sizing: border-box;
36 |
37 | @include mediaMaxWidth($breakpointMedium) {
38 | overflow-x: hidden;
39 | }
40 |
41 | @include mediaMinWidth($breakpointMedium) {
42 |
43 | padding: 5em 0 0 5em;
44 |
45 | &:before {
46 |
47 | position: absolute; content: ""; left: 0; top: 0; right: 0; height: 5em;
48 | background-color: #fff; border-bottom: 1px solid rgba(#000,0.12);
49 |
50 | }
51 |
52 | }
53 |
54 | }
55 |
56 | html {
57 | /* Works on Firefox */
58 | scrollbar-width: none;
59 | scrollbar-color: transparent;
60 |
61 | /* Works on Chrome, Edge, and Safari */
62 | &::-webkit-scrollbar {
63 | width: 0; height: 0;
64 | }
65 |
66 | &::-webkit-scrollbar-track {
67 | background: transparent;
68 | }
69 |
70 | &::-webkit-scrollbar-thumb {
71 | background-color: #000;
72 | border-radius: 10px;
73 | border: 3px solid transparent;
74 | background-clip: content-box;
75 | }
76 | }
77 |
78 | //--------------------------------------------------------------
79 | // Helper classes
80 | //--------------------------------------------------------------
81 | .cf { @include clearfix; }
82 | .nBtn { @include normalizeButton; }
83 | .icr { @include iconReplacement; }
84 | .hidden { display: none !important; }
85 |
86 | //--------------------------------------------------------------
87 | // Overrides
88 | //--------------------------------------------------------------
89 | .slbOverlay { z-index: 10010; }
90 | .slbWrapOuter { z-index: 10020; }
91 |
--------------------------------------------------------------------------------
/src/js/listElements/link.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
71 |
72 |
95 |
--------------------------------------------------------------------------------
/server/client.js:
--------------------------------------------------------------------------------
1 | import BrowserServer from 'json-api-shop/servers/browser.js';
2 | import MemoryAdapter from 'json-api-shop/adapters/memory.js';
3 | import resources from './resources.js';
4 |
5 | const isDevelopment = process.env.NODE_ENV !== 'production';
6 | const currentDataVersion = process.env.PACKAGE_VERSION;
7 |
8 | const LocalStorageAdapter = MemoryAdapter.extend({
9 |
10 | seed() {
11 |
12 | try {
13 | const rawStoredDataset = localStorage.getItem('trimDataset');
14 | const storedDataset = JSON.parse(rawStoredDataset);
15 |
16 | if (storedDataset.version !== currentDataVersion) {
17 | throw new Error('Dataset version mismatch');
18 | } else {
19 | this.dataset = storedDataset.dataset;
20 | }
21 |
22 | } catch (error) {
23 | this.resetData();
24 | }
25 |
26 | },
27 |
28 | resetData() {
29 |
30 | MemoryAdapter.prototype.seed.call(this);
31 | this.persistToStorage();
32 |
33 | },
34 |
35 | persistToStorage() {
36 |
37 | try {
38 | localStorage.setItem('trimDataset', JSON.stringify({
39 | version: currentDataVersion,
40 | dataset: this.dataset
41 | }));
42 | } catch (error) {
43 | if (isDevelopment) {
44 | console.log(error);
45 | }
46 | }
47 | return Promise.resolve();
48 |
49 | }
50 |
51 | });
52 | const Server = BrowserServer.extend({
53 |
54 | resetData() {
55 |
56 | this.database.resetData();
57 | return this;
58 |
59 | },
60 |
61 | setupRoutes(app) {
62 |
63 | app.post(process.env.BASE_API_URL + 'media/upload', (request, response) => {
64 | response.set('Content-Type', 'application/javascript');
65 | response.set('Access-Control-Expose-Headers', 'Location');
66 | response.set('Location', 'https://trikoder.github.io' + process.env.BASE_API_URL + 'media/1');
67 | response.send('');
68 | });
69 |
70 | return BrowserServer.prototype.setupRoutes.call(this, app);
71 |
72 | }
73 |
74 | });
75 |
76 | export default new Server({
77 | baseUrl: process.env.BASE_API_URL,
78 | databaseAdapter: LocalStorageAdapter,
79 | resources,
80 | logResponse: isDevelopment
81 | }).start();
82 |
--------------------------------------------------------------------------------
/src/js/dependencies/vue-dismiss/index.js:
--------------------------------------------------------------------------------
1 | const elementClickKey = '_vueDismissClick';
2 | const elementKeyupKey = '_vueDismissKeyup';
3 | const elementTimeoutKey = '_vueDismissTimeout';
4 |
5 | function setup(el, binding) {
6 |
7 | let callback, shouldBind;
8 |
9 | if (typeof binding.value === 'function') {
10 | callback = binding.value;
11 | shouldBind = true;
12 | } else {
13 | callback = binding.value.callback;
14 | shouldBind = Object.prototype.hasOwnProperty.call(binding.value, 'watch')
15 | ? Boolean(binding.value.watch)
16 | : true
17 | ;
18 | }
19 |
20 | if (shouldBind) {
21 |
22 | if (typeof document !== 'undefined' && !el[elementClickKey]) {
23 |
24 | const handler = function(event) {
25 | if (event.keyCode) {
26 | if (event.keyCode === 27) {
27 | callback();
28 | }
29 | } else if (
30 | !(event.target === el) &&
31 | !el.contains(event.target)
32 | ) {
33 | callback();
34 | }
35 | };
36 |
37 | if (el[elementTimeoutKey]) {
38 | clearTimeout(el[elementTimeoutKey]);
39 | }
40 |
41 | el[elementTimeoutKey] = setTimeout(function() {
42 | document.addEventListener('click', handler);
43 | document.addEventListener('keyup', handler);
44 | }, 10);
45 |
46 | el[elementClickKey] = el[elementKeyupKey] = handler;
47 |
48 | }
49 |
50 | } else {
51 |
52 | unbind(el);
53 |
54 | }
55 |
56 | }
57 |
58 | function unbind(el) {
59 |
60 | if (el[elementClickKey] && typeof document !== 'undefined') {
61 | document.removeEventListener('click', el[elementClickKey]);
62 | document.removeEventListener('keyup', el[elementKeyupKey]);
63 | delete el[elementClickKey];
64 | delete el[elementKeyupKey];
65 | if (el[elementTimeoutKey]) {
66 | clearTimeout(el[elementTimeoutKey]);
67 | delete el[elementTimeoutKey];
68 | }
69 | }
70 |
71 | }
72 |
73 | export default {
74 | directives: {
75 | onDismiss: {
76 | beforeMount: setup,
77 | updated: setup,
78 | unmounted: unbind
79 | }
80 | }
81 | };
82 |
--------------------------------------------------------------------------------
/src/scss/library/helpers/_iconFont.scss:
--------------------------------------------------------------------------------
1 | // Icon font helpers
2 |
3 | @use 'sass:string';
4 |
5 | @mixin iconElement($paddingLeft: 40) {
6 |
7 | display: inline-block; position: relative; padding-left: $paddingLeft*1px;
8 |
9 | }
10 |
11 | @mixin iconElementGraphic($width: 40, $height: 40) {
12 |
13 | position: absolute; left: 0; top: 50%;
14 | margin:0; width: $width*1px; height: $height*1px; line-height: $height*1px; margin-top: ($height*0.5)*-1px;
15 | text-align: center; text-indent: 0;
16 |
17 | }
18 |
19 | @mixin iconFont($fontName) {
20 |
21 | font-family: $fontName;
22 | speak: none;
23 | font-style: normal;
24 | font-weight: normal;
25 | font-variant: normal;
26 | text-transform: none;
27 | -webkit-font-smoothing: antialiased;
28 | -moz-osx-font-smoothing: grayscale;
29 |
30 | }
31 |
32 | @mixin defineIconFont($fontName, $basePath, $version: 'v1', $iconMap: false, $sizeIconGraphic: 40, $classPrefix: 'icon') {
33 |
34 | @font-face {
35 | font-family: $fontName;
36 | src: url('#{$basePath}.eot?#{$version}');
37 | src: url('#{$basePath}.eot?#{$version}#iefix') format('embedded-opentype'),
38 | url('#{$basePath}.woff?#{$version}') format('woff'),
39 | url('#{$basePath}.ttf?#{$version}') format('truetype'),
40 | url('#{$basePath}.svg?#{$version}#{$basePath}') format('svg');
41 | font-weight: normal;
42 | font-style: normal;
43 | }
44 |
45 | @if $iconMap {
46 | @include generateIconsCss($fontName, $iconMap, $sizeIconGraphic, $classPrefix);
47 | }
48 |
49 | }
50 |
51 | @mixin generateIconsCss($fontName, $iconMap, $sizeIconGraphic, $classPrefix) {
52 |
53 | [class^="#{$classPrefix}"],
54 | [class*=" #{$classPrefix}"] {
55 | @include iconElement($sizeIconGraphic);
56 | }
57 |
58 | [class^="#{$classPrefix}"]:before,
59 | [class*=" #{$classPrefix}"]:before {
60 | @include iconFont($fontName);
61 | @include iconElementGraphic($sizeIconGraphic, $sizeIconGraphic);
62 | }
63 |
64 | @each $iconName, $iconContent in $iconMap {
65 | .#{$classPrefix}#{string.to-upper-case(string.slice($iconName, 1, 1)) + string.slice($iconName, 2)}:before {
66 | content: $iconContent;
67 | }
68 | }
69 |
70 | }
71 |
72 | @mixin iconReplacement($width: 40, $height: 40) {
73 |
74 | padding: 0; display: inline-block; width: $width*1px; height: $height*1px;
75 | text-indent: -9999em; overflow: hidden;
76 |
77 | &:before {
78 | right: 0; width: auto; text-indent: 0;
79 | }
80 |
81 | }
82 |
--------------------------------------------------------------------------------
/src/js/listElements/button.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
74 |
75 |
105 |
--------------------------------------------------------------------------------
/src/js/formElements/checkbox.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
53 |
54 |
103 |
--------------------------------------------------------------------------------
/src/js/listElements/base.js:
--------------------------------------------------------------------------------
1 | import {stripTags, escapeHtml, limitCharacters, limitWords, result} from '../library/toolkit.js';
2 |
3 | export default {
4 |
5 | props: {
6 | resourceModel: {type: Object, required: true},
7 | caption: {type: String, required: true},
8 | prependCaption: {type: Boolean, default: false},
9 | mapTo: {type: [String, Function], required: true},
10 | stripTags: {type: Boolean, default: false},
11 | escapeHtml: {type: Boolean, default: false},
12 | limitCharacters: {type: [Boolean, Number], default: false},
13 | limitWords: {type: [Boolean, Number], default: false},
14 | attributes: {type: Object, default: () => ({})},
15 | cellClass: {type: String}
16 | },
17 |
18 | computed: {
19 |
20 | styleAttribute() {
21 |
22 | return this.attributes.style;
23 |
24 | },
25 |
26 | classAttribute() {
27 |
28 | let defaultClass = '';
29 | if ('defaultClass' in this) {
30 | defaultClass = this.defaultClass;
31 | }
32 |
33 | return this.attributes.class || defaultClass;
34 |
35 | }
36 |
37 | },
38 |
39 | methods: {
40 |
41 | getModelValue() {
42 |
43 | if (typeof this.mapTo === 'function') {
44 | return this.mapTo(this.resourceModel, this);
45 | } else {
46 | return this.resourceModel.get(this.mapTo);
47 | }
48 |
49 | },
50 |
51 | formatModelValue() {
52 |
53 | let value = this.getModelValue();
54 |
55 | if (Array.isArray(value)) {
56 | value = value.join(this.collectionDelimiter);
57 | }
58 |
59 | if (value) {
60 |
61 | if (this.stripTags) {
62 | value = stripTags(value);
63 | }
64 |
65 | if (this.escapeHtml) {
66 | value = escapeHtml(value);
67 | }
68 |
69 | if (this.limitCharacters) {
70 | value = limitCharacters(value, this.limitCharacters);
71 | }
72 |
73 | if (this.limitWords) {
74 | value = limitWords(value, this.limitWords);
75 | }
76 |
77 | }
78 |
79 | if (typeof this.ifEmpty !== 'undefined' && (value === null || value === undefined || value === '')) {
80 | value = result(this.ifEmpty, [this.resourceModel], this);
81 | }
82 |
83 | if (this.prependCaption) {
84 | value = this.caption + ': ' + value;
85 | }
86 |
87 | return value;
88 |
89 | }
90 |
91 | }
92 |
93 | };
94 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-commonjs */
2 | module.exports = {
3 | ignorePatterns: ['docs/'],
4 | root: true,
5 | env: {
6 | browser: true,
7 | mocha: true
8 | },
9 | extends: [
10 | 'plugin:vue/vue3-essential',
11 | 'standard'
12 | ],
13 | plugins: [
14 | 'vue',
15 | 'node',
16 | 'import'
17 | ],
18 | settings: {
19 | 'import/resolver': {
20 | alias: {
21 | map: [
22 | ['trim', './src/'],
23 | ['apiServer', './server/client.js']
24 | ]
25 | }
26 | }
27 | },
28 | rules: {
29 | 'import/no-commonjs': 'error',
30 | 'import/no-unresolved': 'error',
31 | 'vue/no-deprecated-events-api': 'warn',
32 | 'vue/multi-word-component-names': 'off',
33 | 'node/file-extension-in-import': ['error', 'always'],
34 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
35 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
36 | 'padded-blocks': 'off',
37 | 'no-mixed-operators': 'off',
38 | indent: ['error', 4, {SwitchCase: 1}],
39 | 'linebreak-style': ['error', 'unix'],
40 | quotes: ['error', 'single'],
41 | semi: ['error', 'always'],
42 | 'no-unused-vars': ['error', {args: 'none'}],
43 | 'array-bracket-spacing': 'error',
44 | 'block-spacing': 'error',
45 | 'brace-style': ['error', '1tbs', {allowSingleLine: true}],
46 | camelcase: 'error',
47 | 'comma-spacing': 'error',
48 | 'computed-property-spacing': 'error',
49 | curly: 'error',
50 | 'eol-last': 'error',
51 | eqeqeq: 'error',
52 | 'func-call-spacing': 'error',
53 | 'key-spacing': 'error',
54 | 'keyword-spacing': 'error',
55 | 'max-depth': ['error', 5],
56 | 'max-nested-callbacks': ['error', 4],
57 | 'new-cap': ['error', {capIsNew: false, newIsCapExceptions: ['default']}],
58 | 'no-multiple-empty-lines': ['error', {max: 1}],
59 | 'no-extra-bind': 'error',
60 | 'no-implicit-coercion': 'error',
61 | 'no-implicit-globals': 'error',
62 | 'no-useless-concat': 'error',
63 | 'no-useless-return': 'error',
64 | 'no-trailing-spaces': 'error',
65 | 'no-multi-spaces': 'error',
66 | 'no-whitespace-before-property': 'error',
67 | 'object-curly-spacing': ['error', 'never'],
68 | 'space-before-blocks': 'error',
69 | 'space-before-function-paren': ['error', 'never'],
70 | 'space-in-parens': ['error'],
71 | 'vue/no-use-v-if-with-v-for': 'off',
72 | 'wrap-iife': 'error'
73 | }
74 | };
75 |
--------------------------------------------------------------------------------
/src/js/formElements/nestedSelect/treeNode.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
18 |
33 |
34 |
35 |
36 |
107 |
--------------------------------------------------------------------------------
/src/font/icons/demo-files/demo.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 0;
3 | margin: 0;
4 | font-family: sans-serif;
5 | font-size: 1em;
6 | line-height: 1.5;
7 | color: #555;
8 | background: #fff;
9 | }
10 | h1 {
11 | font-size: 1.5em;
12 | font-weight: normal;
13 | }
14 | small {
15 | font-size: .66666667em;
16 | }
17 | a {
18 | color: #e74c3c;
19 | text-decoration: none;
20 | }
21 | a:hover, a:focus {
22 | box-shadow: 0 1px #e74c3c;
23 | }
24 | .bshadow0, input {
25 | box-shadow: inset 0 -2px #e7e7e7;
26 | }
27 | input:hover {
28 | box-shadow: inset 0 -2px #ccc;
29 | }
30 | input, fieldset {
31 | font-family: sans-serif;
32 | font-size: 1em;
33 | margin: 0;
34 | padding: 0;
35 | border: 0;
36 | }
37 | input {
38 | color: inherit;
39 | line-height: 1.5;
40 | height: 1.5em;
41 | padding: .25em 0;
42 | }
43 | input:focus {
44 | outline: none;
45 | box-shadow: inset 0 -2px #449fdb;
46 | }
47 | .glyph {
48 | font-size: 16px;
49 | width: 15em;
50 | padding-bottom: 1em;
51 | margin-right: 4em;
52 | margin-bottom: 1em;
53 | float: left;
54 | overflow: hidden;
55 | }
56 | .liga {
57 | width: 80%;
58 | width: calc(100% - 2.5em);
59 | }
60 | .talign-right {
61 | text-align: right;
62 | }
63 | .talign-center {
64 | text-align: center;
65 | }
66 | .bgc1 {
67 | background: #f1f1f1;
68 | }
69 | .fgc1 {
70 | color: #999;
71 | }
72 | .fgc0 {
73 | color: #000;
74 | }
75 | p {
76 | margin-top: 1em;
77 | margin-bottom: 1em;
78 | }
79 | .mvm {
80 | margin-top: .75em;
81 | margin-bottom: .75em;
82 | }
83 | .mtn {
84 | margin-top: 0;
85 | }
86 | .mtl, .mal {
87 | margin-top: 1.5em;
88 | }
89 | .mbl, .mal {
90 | margin-bottom: 1.5em;
91 | }
92 | .mal, .mhl {
93 | margin-left: 1.5em;
94 | margin-right: 1.5em;
95 | }
96 | .mhmm {
97 | margin-left: 1em;
98 | margin-right: 1em;
99 | }
100 | .mls {
101 | margin-left: .25em;
102 | }
103 | .ptl {
104 | padding-top: 1.5em;
105 | }
106 | .pbs, .pvs {
107 | padding-bottom: .25em;
108 | }
109 | .pvs, .pts {
110 | padding-top: .25em;
111 | }
112 | .unit {
113 | float: left;
114 | }
115 | .unitRight {
116 | float: right;
117 | }
118 | .size1of2 {
119 | width: 50%;
120 | }
121 | .size1of1 {
122 | width: 100%;
123 | }
124 | .clearfix:before, .clearfix:after {
125 | content: " ";
126 | display: table;
127 | }
128 | .clearfix:after {
129 | clear: both;
130 | }
131 | .hidden-true {
132 | display: none;
133 | }
134 | .textbox0 {
135 | width: 3em;
136 | background: #f1f1f1;
137 | padding: .25em .5em;
138 | line-height: 1.5;
139 | height: 1.5em;
140 | }
141 | #testDrive {
142 | display: block;
143 | padding-top: 24px;
144 | line-height: 1.5;
145 | }
146 | .fs0 {
147 | font-size: 16px;
148 | }
149 | .fs1 {
150 | font-size: 24px;
151 | }
152 |
153 |
--------------------------------------------------------------------------------
/documentation/.vitepress/assets/trikoder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/demo/controllers/user.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | resourceName: 'user',
4 | resourceCaption: 'email',
5 | createRequiresDraft: true,
6 | // createRelatedStrategy: 'relatedLast',
7 |
8 | setupList({list}) {
9 |
10 | this.addCreateControl('Create new user');
11 |
12 | // --------------------------------------------------------------
13 | // Filters
14 | // --------------------------------------------------------------
15 | list.addFilter('TextFormElement', {
16 | name: 'email',
17 | label: 'Email'
18 | });
19 |
20 | // --------------------------------------------------------------
21 | // List items
22 | // --------------------------------------------------------------
23 | list.addItem('TextListItem', {
24 | caption: 'ID',
25 | mapTo: 'id',
26 | addIf: this.screenIsLarge
27 | });
28 |
29 | list.addItem('LinkListItem', {
30 | caption: 'Email',
31 | mapTo: 'email',
32 | action: 'editItem'
33 | });
34 |
35 | list.addItem('TextListItem', {
36 | caption: 'Contact data',
37 | mapTo: model => model.get('contactData') && model.get('contactData').map(item => {
38 | return item.get('label') + ': ' + item.get('entry');
39 | }).join(' / '),
40 | ifEmpty: 'No contact data'
41 | });
42 |
43 | list.addItem('ContextMenuListItem', {
44 | caption: 'Actions',
45 | items: [{caption: 'Edit', action: 'editItem'}]
46 | });
47 |
48 | },
49 |
50 | setupEdit({edit, method}) {
51 |
52 | this.addToIndexControl().addSaveControl();
53 |
54 | edit.addField('TextFormElement', {
55 | label: 'Email',
56 | name: 'email',
57 | attributes: {input: {class: 'inputType2 size2'}}
58 | });
59 |
60 | edit.addField('TextFormElement', {
61 | label: method === 'create' ? 'Password' : 'Change password',
62 | saveEmptyValue: false,
63 | name: 'password',
64 | attributes: {input: {type: 'password'}}
65 | });
66 |
67 | edit.addField('IncludedAdminFormElement', {
68 | label: 'Contacts',
69 | name: 'contactData',
70 | updatePosition: true,
71 | setupEdit: ({editIncluded}) => {
72 |
73 | editIncluded.addField('TextFormElement', {
74 | label: 'Contact label',
75 | name: 'label'
76 | });
77 |
78 | editIncluded.addField('TextFormElement', {
79 | label: 'Contact entry',
80 | name: 'entry'
81 | });
82 |
83 | },
84 | relation: {resourceName: 'userContactEntry'}
85 | });
86 |
87 | }
88 |
89 | };
90 |
--------------------------------------------------------------------------------
/demo/mainNavigation.js:
--------------------------------------------------------------------------------
1 | // import auth from './auth';
2 | import server from 'apiServer';
3 | import DummyComponent from './dummy.vue';
4 |
5 | export default {
6 |
7 | getAdditionalComponents: () => ({
8 | userPanel: DummyComponent
9 | }),
10 |
11 | getNavigationItems: router => [
12 |
13 | {
14 | caption: 'Articles',
15 | key: 'article',
16 | url: router.url('resource.article.index'),
17 | icon: 'file'
18 | },
19 |
20 | {
21 | caption: 'Pages',
22 | key: 'page',
23 | url: router.url('resource.page.index'),
24 | icon: 'sidebar'
25 | },
26 |
27 | {
28 | caption: 'Multimedia',
29 | key: 'media',
30 | url: router.url('resource.media.index'),
31 | icon: 'instagram'
32 | },
33 |
34 | {
35 | caption: 'Application users',
36 | key: 'user',
37 | url: router.url('resource.user.index'),
38 | icon: 'user'
39 | },
40 |
41 | {
42 | caption: 'Miscellaneous',
43 | icon: 'moreHorizontal',
44 | subItems: [
45 |
46 | {
47 | caption: 'Categories',
48 | key: 'category',
49 | url: router.url('resource.category.index')
50 | },
51 |
52 | {
53 | caption: 'Html snippets',
54 | key: 'snippet',
55 | url: router.url('resource.snippet.index')
56 | },
57 |
58 | {
59 | caption: 'Tags',
60 | key: 'tag',
61 | url: router.url('resource.tag.index')
62 | }
63 |
64 | ]
65 | }
66 |
67 | ],
68 |
69 | getUserNavigationItems: router => [
70 |
71 | {
72 | caption: 'Documentation',
73 | url: process.env.BASE_ABSOLUTE_URL + '/trim/',
74 | appLink: false
75 | },
76 |
77 | {
78 | caption: 'My settings',
79 | url: router.url('mySettings')
80 | },
81 |
82 | {
83 | caption: 'Reset demo data',
84 | action: () => {
85 | server.resetData();
86 | window.location.reload();
87 | }
88 | },
89 |
90 | {
91 | key: 'showSearch',
92 | caption: 'Show search (Shift + l)',
93 | action: mainNavigation => mainNavigation.showSearch().close()
94 | }
95 |
96 | // {
97 | // caption: 'Logout',
98 | // action: () => auth.logout()
99 | // }
100 |
101 | ],
102 |
103 | getProjectCaption: () => 'Trikoder Trim CMS',
104 |
105 | getUserCaption: () => 'Demo user'
106 |
107 | };
108 |
--------------------------------------------------------------------------------
/demo/controllers/article/imageInContent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
71 |
72 |
105 |
--------------------------------------------------------------------------------
/src/js/components/resourceSort.vue:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
18 |
56 |
57 |
111 |
--------------------------------------------------------------------------------
/src/js/formElements/code.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
101 |
102 |
127 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@trikoder/trim",
3 | "version": "0.86.0",
4 | "type": "module",
5 | "description": "UI framework for building headless content management systems that connect to JSON:API powered backend.",
6 | "homepage": "https://trikoder.github.io/trim/",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/trikoder/trim"
10 | },
11 | "license": "MIT",
12 | "files": [
13 | "/src"
14 | ],
15 | "scripts": {
16 | "dev": "npm run api:server & npm run dev:server",
17 | "dev:server": "NODE_ENV=development BASE_URL=/ BASE_API_URL=http://localhost:8083/ vite --mode development",
18 | "dev:docs": "NODE_ENV=development vitepress dev documentation",
19 | "build": "npm run build:docs && npm run build:demo",
20 | "build:demo": "NODE_ENV=production CLIENT_API_ENABLED=true BASE_URL=/trim/demo/ BASE_API_URL=/trim/demo/api/ vite build --mode production",
21 | "build:docs": "vitepress build documentation --minify",
22 | "api:server": "BASE_API_URL=http://localhost:8083/ API_PORT=8083 node ./server/node.js",
23 | "preview:pages": "node ./server/previewPages.js",
24 | "lint": "eslint '{src,demo,server,documentation}/**/*.{js,vue}' '{vite.config,.babelrc,.eslintrc}.{js,cjs}' --fix",
25 | "prepare": "husky install",
26 | "prerelease": "npm run lint",
27 | "release": "np --no-release-draft"
28 | },
29 | "dependencies": {
30 | "@googlemaps/js-api-loader": "^1.16.8",
31 | "axios": "^1.1.3",
32 | "ckeditor4": "4.22.1",
33 | "codemirror": "^5.65.9",
34 | "dragula": "^3.7.3",
35 | "dropzone": "^5.9.3",
36 | "escape-html": "^1.0.3",
37 | "fecha": "^4.2.3",
38 | "fuse.js": "^6.6.2",
39 | "json-api-resource": "^0.8.0",
40 | "little-loader": "^0.2.0",
41 | "mitt": "^3.0.0",
42 | "normalize.css": "^8.0.0",
43 | "simple-lightbox": "^2.0.1",
44 | "to-case": "^2.0.0",
45 | "translate-js": "^1.2.0",
46 | "vue": "^3.3.4",
47 | "vue-global-events": "^2.1.1",
48 | "vue-multiselect": "^3.0.0-beta.2",
49 | "vue-router": "^4.2.2",
50 | "vuex": "^4.1.0"
51 | },
52 | "devDependencies": {
53 | "@babel/core": "^7.22.1",
54 | "@babel/preset-env": "^7.22.4",
55 | "@vitejs/plugin-vue": "^5.2.1",
56 | "babel-plugin-istanbul": "^5.1.4",
57 | "eslint": "^8.41.0",
58 | "eslint-config-standard": "^17.1.0",
59 | "eslint-import-resolver-alias": "^1.1.2",
60 | "eslint-plugin-import": "^2.27.5",
61 | "eslint-plugin-node": "^11.1.0",
62 | "eslint-plugin-promise": "^6.1.1",
63 | "eslint-plugin-vue": "^9.14.1",
64 | "express": "^4.17.0",
65 | "fkill": "^8.1.1",
66 | "husky": "^8.0.3",
67 | "json-api-shop": "^0.7.2",
68 | "lint-staged": "^13.2.2",
69 | "mout": "^1.2.4",
70 | "np": "^10.1.0",
71 | "postcss-preset-env": "^8.4.1",
72 | "sass": "^1.81.0",
73 | "vite": "^5.4.11",
74 | "vitepress": "^1.5.0"
75 | },
76 | "engines": {
77 | "node": ">= 18.0.0",
78 | "npm": ">= 9.0.0"
79 | },
80 | "browserslist": [
81 | "last 4 versions"
82 | ],
83 | "lint-staged": {
84 | "*.{js,vue}": [
85 | "eslint --fix"
86 | ]
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/js/library/auth.js:
--------------------------------------------------------------------------------
1 | import serviceContainer from './serviceContainer.js';
2 | import {assign} from './toolkit.js';
3 |
4 | export default {
5 |
6 | component: () => serviceContainer.get('AuthController'),
7 |
8 | loginRoute: {
9 | path: '/login',
10 | name: 'login',
11 | isExternal: false
12 | },
13 |
14 | afterLoginRoute: {path: '/'},
15 |
16 | setup({router, beforeAdminEnter}) {
17 |
18 | this.router = router;
19 | this.beforeAdminEnter = beforeAdminEnter;
20 |
21 | if (!this.loginRoute.isExternal) {
22 | router.addRoutes([{
23 | path: this.loginRoute.path,
24 | name: this.loginRoute.name,
25 | component: this.component,
26 | props: {authApi: this}
27 | }]);
28 | }
29 |
30 | router.beforeEach((to, from, next) => {
31 |
32 | if (to.meta.requiresAuth) {
33 | Promise.resolve(this.isUserLogged()).then(isUserLoggedIn => {
34 | if (isUserLoggedIn) {
35 | this.bootstrapEnvironment().then(() => next());
36 | } else {
37 | if (this.loginRoute.isExternal) {
38 | window.location.href = this.loginRoute.path;
39 | } else {
40 | next({name: this.loginRoute.name});
41 | }
42 | }
43 | });
44 | } else {
45 | next();
46 | }
47 |
48 | });
49 |
50 | },
51 |
52 | bootstrapEnvironment() {
53 |
54 | if (!this.onAuthorization.promise) {
55 | this.onAuthorization.promise = Promise.resolve(this.onAuthorization());
56 | }
57 |
58 | return this.onAuthorization.promise.then(() => {
59 |
60 | if (!this.beforeAdminEnter.promise) {
61 | this.beforeAdminEnter.promise = Promise.resolve(this.beforeAdminEnter());
62 | }
63 |
64 | return this.beforeAdminEnter.promise;
65 |
66 | });
67 |
68 | },
69 |
70 | teardownEnvironment() {
71 |
72 | delete this.beforeAdminEnter.promise;
73 | delete this.onAuthorization.promise;
74 |
75 | return Promise.resolve(this.onDeauthorization());
76 |
77 | },
78 |
79 | isUserLogged() {
80 |
81 | throw new Error('Method "isUserLogged" not implemented on auth api');
82 |
83 | },
84 |
85 | loginWithCredentials(credentials) {
86 |
87 | throw new Error('Method "loginWithCredentials" not implemented on auth api');
88 |
89 | },
90 |
91 | logout() {
92 |
93 | return this.teardownEnvironment().then(() => {
94 | if (this.loginRoute.isExternal) {
95 | window.location.href = this.loginRoute.path;
96 | } else {
97 | this.router.push({name: this.loginRoute.name});
98 | }
99 | });
100 |
101 | },
102 |
103 | onAuthorization() {},
104 |
105 | onDeauthorization() {},
106 |
107 | extend(params) {
108 |
109 | assign(this, params);
110 | return this;
111 |
112 | }
113 |
114 | };
115 |
--------------------------------------------------------------------------------
/src/js/appServices.js:
--------------------------------------------------------------------------------
1 | import MainNavigation from './components/mainNavigation.vue';
2 | import BaseResourceController from './controllers/baseResource.vue';
3 |
4 | import TextListItem from './listElements/text.vue';
5 | import LinkListItem from './listElements/link.vue';
6 | import DateListItem from './listElements/date.vue';
7 | import DateTimeListItem from './listElements/dateTime.vue';
8 | import BlipListItem from './listElements/blip.vue';
9 | import ButtonListItem from './listElements/button.vue';
10 | import ContextMenuListItem from './listElements/contextMenu.vue';
11 |
12 | import TextFormElement from './formElements/text.vue';
13 | import TextareaFormElement from './formElements/textarea.vue';
14 | import CheckboxFormElement from './formElements/checkbox.vue';
15 | import SelectFormElement from './formElements/select.vue';
16 | import ExternalAdminFormElement from './formElements/externalAdmin.vue';
17 | import IncludedAdminFormElement from './formElements/includedAdmin.vue';
18 |
19 | export default {
20 |
21 | // Common components
22 | MainNavigation: () => MainNavigation,
23 | BaseMainNavigation: () => MainNavigation,
24 | AppSearch: () => import('./components/appSearch.vue'),
25 |
26 | // Controllers
27 | BaseResourceController: () => BaseResourceController,
28 | BaseResourceEditController: () => import('./controllers/baseResourceEdit.vue'),
29 | BaseMediaResourceController: () => import('./controllers/baseMediaResource.vue'),
30 | BaseNestedResourceController: () => import('./controllers/baseNestedResource.vue'),
31 | ErrorController: () => import('./controllers/error.vue'),
32 | AuthController: () => import('./controllers/auth.vue'),
33 |
34 | // List items
35 | TextListItem: () => TextListItem,
36 | LinkListItem: () => LinkListItem,
37 | DateListItem: () => DateListItem,
38 | DateTimeListItem: () => DateTimeListItem,
39 | BlipListItem: () => BlipListItem,
40 | ButtonListItem: () => ButtonListItem,
41 | MediaListItem: () => import('./listElements/media.vue'),
42 | ContextMenuListItem: () => ContextMenuListItem,
43 |
44 | // Form elements
45 | TextFormElement: () => TextFormElement,
46 | NumberFormElement: () => import('./formElements/number.vue'),
47 | TextareaFormElement: () => TextareaFormElement,
48 | CheckboxFormElement: () => CheckboxFormElement,
49 | SelectFormElement: () => SelectFormElement,
50 | ExternalAdminFormElement: () => ExternalAdminFormElement,
51 | IncludedAdminFormElement: () => IncludedAdminFormElement,
52 | StateSelectFormElement: () => import('./formElements/stateSelect.vue'),
53 | DateFormElement: () => import('./formElements/date.vue'),
54 | DateTimeFormElement: () => import('./formElements/dateTime.vue'),
55 | HiddenFormElement: () => import('./formElements/hidden.vue'),
56 | HtmlFormElement: () => import('./formElements/html.vue'),
57 | HtmlComponentsFormElement: () => import('./formElements/htmlComponents/index.vue'),
58 | MapFormElement: () => import('./formElements/map.vue'),
59 | MultipleSelectFormElement: () => import('./formElements/multipleSelect.vue'),
60 | FileAttachmentFormElement: () => import('./formElements/fileAttachment.vue'),
61 | MediaPreviewFormElement: () => import('./formElements/mediaPreview.vue'),
62 | MediaFormElement: () => import('./formElements/media.vue'),
63 | NestedSelectFormElement: () => import('./formElements/nestedSelect/index.vue'),
64 | CodeFormElement: () => import('./formElements/code.vue'),
65 | ColorFormElement: () => import('./formElements/color.vue')
66 |
67 | };
68 |
--------------------------------------------------------------------------------
/documentation/about.md:
--------------------------------------------------------------------------------
1 | # About
2 | Trikoder Trim is user interface framework for building headless content management systems that connect to JSON:API powered backend.
3 | Craft responsive single page applications that work on all devices.
4 |
5 | Content management systems built on top of Trikoder Trim are decoupled from server side technology stack.
6 | UI framework works nicely with any server side technology that can process and render JSON dataset compliant with [JSON:API specification](http://jsonapi.org/).
7 |
8 | Trim enables you to quickly build administration CRUD (create, read, update, delete) interface for your application resources. Resulting CMS is responsive and fast - all styles and behavior for standard use cases come included - developers job is only to define how each application resource is listed and edited.
9 |
10 | Sensible dependency on standardized backend API enables us to create CMS domain specific language or API in JS that is pretty much decoupled from JS libraries and frameworks that are used underneath. Any capable developer should be able to define complete interface for resource in need of administration.
11 |
12 |
13 | ## Technology and tooling
14 | Trikoder Trim is built on following open source stack:
15 |
16 | * [Vue](https://vuejs.org/), [Vue Router](https://router.vuejs.org/) and [Vuex](https://vuex.vuejs.org/) are used for application views, routing and store management.
17 | * [Axios](https://github.com/axios/axios) is used as http client
18 | * [JSON:API resource](https://dbrekalo.github.io/json-api-resource/) is used for querying and persisting resources
19 | * [Vite](https://vitejs.dev/) or [Webpack](https://webpack.js.org/) is used for module bundling and code splitting
20 |
21 | ## Code sneek peek
22 | Let’s assume your application has a simple `tag` resource and backend API for this resource is ready.
23 | You want to show list of tags that can be filtered by title.
24 | Additionally you want to setup create and edit interface with input for setting tag title.
25 | Your code should end up looking something like this:
26 |
27 | ```js
28 | export default {
29 |
30 | resourceName: 'tag',
31 |
32 | setupList: function({list}) {
33 |
34 | this.addCreateControl('Create new tag');
35 |
36 | list.addFilter('TextFormElement', {
37 | name: 'title',
38 | label: 'Title'
39 | });
40 |
41 | list.addItem('TextListItem', {
42 | caption: 'ID',
43 | mapTo: 'id'
44 | });
45 |
46 | list.addItem('LinkListItem', {
47 | caption: 'Title',
48 | mapTo: 'title',
49 | action: 'editItem'
50 | });
51 |
52 | },
53 |
54 | setupEdit: function({edit}) {
55 |
56 | this.addToIndexControl().addSaveControl();
57 |
58 | edit.addField('TextFormElement', {
59 | label: 'Title',
60 | name: 'title'
61 | });
62 |
63 | }
64 |
65 | };
66 | ```
67 |
68 | ## Demo application
69 | Visit [demo application](https://trikoder.github.io/trim/demo/index.html) to get a feeling how CMS built with Trikoder CMF looks and behaves. Is is completely safe to browse, edit and delete items - backend API on demo pages is running on client JSON:API server that stores data in browser memory - so no harm can be done. Dataset can be reset by clicking "reset demo data" control in lower left corner of administration UI. Examine how everything is composed in [demo codebase](https://github.com/trikoder/trim/tree/master/demo).
70 |
71 | Feel free to browse, cut and paste from demo codebase for your CMS needs and use it as reference.
72 |
--------------------------------------------------------------------------------
/src/js/components/resourceListTable.vue:
--------------------------------------------------------------------------------
1 |
2 |
56 |
57 |
58 |
95 |
--------------------------------------------------------------------------------
/src/js/components/loader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
32 |
33 |
136 |
--------------------------------------------------------------------------------
/src/js/app.js:
--------------------------------------------------------------------------------
1 | import {createApp, toRaw} from 'vue';
2 | import AppView from './components/appView.vue';
3 | import AdminDefaultLayout from './layouts/adminDefault.vue';
4 | import {create as createRouter} from './library/router.js';
5 | import {ensureArray} from './library/toolkit.js';
6 | import translate from './library/translate.js';
7 | import bootData from './library/bootData.js';
8 | import browserFeatures from './library/browserFeatures.js';
9 | import serviceContainer from './library/serviceContainer.js';
10 | import Loader from './library/loader.js';
11 | import viewport from './library/viewport.js';
12 | import appServices from './appServices.js';
13 | import store from './store.js';
14 |
15 | Loader.setActions({
16 | onActivate: () => store.commit('loading', true),
17 | onDeactivate: () => store.commit('loading', false)
18 | });
19 |
20 | viewport.setBreakpoints({
21 | ranges: {
22 | small: [0, 979],
23 | large: [980, Infinity]
24 | },
25 | onMatch: breakpoint => store.commit('setBreakpoint', breakpoint)
26 | }).checkBreakpoints();
27 |
28 | let routesProvider;
29 | let beforeAdminEnter = () => {};
30 | let auth = false;
31 |
32 | serviceContainer.register(appServices);
33 |
34 | const api = {
35 |
36 | start() {
37 |
38 | browserFeatures.runTests();
39 |
40 | const router = createRouter();
41 |
42 | Promise.resolve(auth
43 | ? auth.setup({router, beforeAdminEnter})
44 | : beforeAdminEnter()
45 | ).then(() => {
46 |
47 | routesProvider(router);
48 | router.controller('*', 'error', 'Error@pageNotFound');
49 |
50 | /* eslint-disable no-new */
51 | this.rootView = createApp(AppView);
52 |
53 | this.rootView.config.globalProperties.toRawComponentProps = function(component) {
54 | return toRaw(component);
55 | };
56 |
57 | this.rootView.use(router);
58 | this.rootView.use(store);
59 |
60 | this.rootView.mount('#app');
61 |
62 | });
63 |
64 | },
65 |
66 | setLocale(locale) {
67 |
68 | translate.setLocale(locale);
69 | return this;
70 |
71 | },
72 |
73 | getLocale() {
74 |
75 | return translate.getLocale();
76 |
77 | },
78 |
79 | loadTranslations(items, locale, prefix) {
80 |
81 | translate.add(items, locale, prefix);
82 | return this;
83 |
84 | },
85 |
86 | setTranslationPluralizationRule(locale, rule, options) {
87 |
88 | translate.setPluralizationRule(locale, rule, options);
89 | return this;
90 |
91 | },
92 |
93 | interpolateTranslationWith(interpolateRE) {
94 |
95 | translate.interpolateWith(interpolateRE);
96 | return this;
97 |
98 | },
99 |
100 | setBootData(data) {
101 |
102 | bootData.set(data);
103 | return this;
104 |
105 | },
106 |
107 | registerServices: function(services) {
108 |
109 | serviceContainer.register(services);
110 | return this;
111 |
112 | },
113 |
114 | registerRoutes: function(userRoutesProvider) {
115 |
116 | routesProvider = userRoutesProvider;
117 | return this;
118 |
119 | },
120 |
121 | useAuth: function(api) {
122 |
123 | auth = api;
124 | return this;
125 |
126 | },
127 |
128 | beforeAdminEnter(callback) {
129 |
130 | beforeAdminEnter = callback;
131 | return this;
132 |
133 | },
134 |
135 | appendAppComponent(Component) {
136 | AdminDefaultLayout.additionalComponents = ensureArray(Component);
137 | return this;
138 | }
139 |
140 | };
141 |
142 | export default api;
143 |
--------------------------------------------------------------------------------
/src/js/formElements/base.vue:
--------------------------------------------------------------------------------
1 |
139 |
--------------------------------------------------------------------------------
/src/js/layouts/adminDefault.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
14 |
15 |
16 |
17 |
18 |
136 |
--------------------------------------------------------------------------------
/src/scss/partials/_webfonts.scss:
--------------------------------------------------------------------------------
1 | @use '../library/all' as *;
2 |
3 | @font-face {
4 | font-family: 'Archivo Narrow';
5 | font-style: normal;
6 | font-weight: 400;
7 | src: url(asset('font/webFonts/archivo-narrow-400-normal.eot'));
8 | src: local('Archivo Narrow Regular'), local('ArchivoNarrow-Regular'), url(asset('font/webFonts/archivo-narrow-400-normal.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/archivo-narrow-400-normal.woff')) format('woff'), url(asset('font/webFonts/archivo-narrow-400-normal.ttf')) format('truetype'), url(asset('font/webFonts/archivo-narrow-400-normal.svg#ArchivoNarrow')) format('svg');
9 | }
10 | @font-face {
11 | font-family: 'Archivo Narrow';
12 | font-style: italic;
13 | font-weight: 400;
14 | src: url(asset('font/webFonts/archivo-narrow-400-italic.eot'));
15 | src: local('Archivo Narrow Italic'), local('ArchivoNarrow-Italic'), url(asset('font/webFonts/archivo-narrow-400-italic.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/archivo-narrow-400-italic.woff')) format('woff'), url(asset('font/webFonts/archivo-narrow-400-italic.ttf')) format('truetype'), url(asset('font/webFonts/archivo-narrow-400-italic.svg#ArchivoNarrow')) format('svg');
16 | }
17 | @font-face {
18 | font-family: 'Archivo Narrow';
19 | font-style: normal;
20 | font-weight: 700;
21 | src: url(asset('font/webFonts/archivo-narrow-700-normal.eot'));
22 | src: local('Archivo Narrow Bold'), local('ArchivoNarrow-Bold'), url(asset('font/webFonts/archivo-narrow-700-normal.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/archivo-narrow-700-normal.woff')) format('woff'), url(asset('font/webFonts/archivo-narrow-700-normal.ttf')) format('truetype'), url(asset('font/webFonts/archivo-narrow-700-normal.svg#ArchivoNarrow')) format('svg');
23 | }
24 | @font-face {
25 | font-family: 'Archivo Narrow';
26 | font-style: italic;
27 | font-weight: 700;
28 | src: url(asset('font/webFonts/archivo-narrow-700-italic.eot'));
29 | src: local('Archivo Narrow Bold Italic'), local('ArchivoNarrow-BoldItalic'), url(asset('font/webFonts/archivo-narrow-700-italic.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/archivo-narrow-700-italic.woff')) format('woff'), url(asset('font/webFonts/archivo-narrow-700-italic.ttf')) format('truetype'), url(asset('font/webFonts/archivo-narrow-700-italic.svg#ArchivoNarrow')) format('svg');
30 | }
31 | @font-face {
32 | font-family: 'Roboto';
33 | font-style: normal;
34 | font-weight: 400;
35 | src: url(asset('font/webFonts/roboto-400-normal.eot'));
36 | src: url(asset('font/webFonts/roboto-400-normal.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/roboto-400-normal.woff')) format('woff'), url(asset('font/webFonts/roboto-400-normal.ttf')) format('truetype'), url(asset('font/webFonts/roboto-400-normal.svg#Roboto')) format('svg');
37 | }
38 | @font-face {
39 | font-family: 'Roboto';
40 | font-style: italic;
41 | font-weight: 400;
42 | src: url(asset('font/webFonts/roboto-400-italic.eot'));
43 | src: url(asset('font/webFonts/roboto-400-italic.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/roboto-400-italic.woff')) format('woff'), url(asset('font/webFonts/roboto-400-italic.ttf')) format('truetype'), url(asset('font/webFonts/roboto-400-italic.svg#Roboto')) format('svg');
44 | }
45 | @font-face {
46 | font-family: 'Roboto';
47 | font-style: normal;
48 | font-weight: 700;
49 | src: url(asset('font/webFonts/roboto-700-normal.eot'));
50 | src: url(asset('font/webFonts/roboto-700-normal.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/roboto-700-normal.woff')) format('woff'), url(asset('font/webFonts/roboto-700-normal.ttf')) format('truetype'), url(asset('font/webFonts/roboto-700-normal.svg#Roboto')) format('svg');
51 | }
52 | @font-face {
53 | font-family: 'Roboto';
54 | font-style: italic;
55 | font-weight: 700;
56 | src: url(asset('font/webFonts/roboto-700-italic.eot'));
57 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(asset('font/webFonts/roboto-700-italic.eot#iefix')) format('embedded-opentype'), url(asset('font/webFonts/roboto-700-italic.woff')) format('woff'), url(asset('font/webFonts/roboto-700-italic.ttf')) format('truetype'), url(asset('font/webFonts/roboto-700-italic.svg#Roboto')) format('svg');
58 | }
59 |
--------------------------------------------------------------------------------
/src/js/components/dialogModal.vue:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 |
23 |
87 |
88 |
153 |
--------------------------------------------------------------------------------
/demo/controllers/media.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | extendsController: 'BaseMediaResource',
4 | resourceName: 'media',
5 | resourceCaption: 'title',
6 | cssClass: 'mediaResourceController',
7 | mediaTypes: ['image', 'file', {
8 | name: 'videoEmbed',
9 | createPageTitle: 'Create video embed',
10 | hasUploadUi: false
11 | }],
12 |
13 | setupList({list}) {
14 |
15 | this.addCreateImageControl('Create new image').addDropdownControl([{
16 | caption: 'Create video embed',
17 | action: this.openCreateVideoEmbed
18 | }, {
19 | caption: 'Upload file',
20 | action: this.openCreateFile
21 | }]);
22 |
23 | // --------------------------------------------------------------
24 | // Filters
25 | // --------------------------------------------------------------
26 | list.addFilter('TextFormElement', {
27 | name: 'title',
28 | label: 'Title'
29 | });
30 |
31 | list.addFilter('TextFormElement', {
32 | name: 'caption',
33 | label: 'Caption'
34 | });
35 |
36 | list.addFilter('SelectFormElement', {
37 | name: 'mediaType',
38 | label: 'Media type',
39 | selectOptions: [
40 | {caption: 'All', value: ''},
41 | {caption: 'Images', value: 'image'},
42 | {caption: 'Files', value: 'file'}
43 | ]
44 | });
45 |
46 | // --------------------------------------------------------------
47 | // List items
48 | // --------------------------------------------------------------
49 |
50 | list.addItem('MediaListItem', {
51 | caption: 'Main media',
52 | mediaType: model => model.get('mediaType'),
53 | mapTo: 'thumbnailUrl',
54 | mapLargeImageTo: 'originalUrl',
55 | mapFileUrlTo: 'url'
56 | });
57 |
58 | list.addItem('LinkListItem', {
59 | caption: 'Title',
60 | mapTo: 'title',
61 | action: 'editItem'
62 | });
63 |
64 | list.addItem('TextListItem', {
65 | caption: 'Caption',
66 | mapTo: 'caption',
67 | escapeHtml: false
68 | });
69 |
70 | list.addItem('TextListItem', {
71 | caption: 'Type',
72 | attributes: {class: 'textListItemType1 mod1'},
73 | mapTo: model => 'Type: ' + model.get('mediaType')
74 | });
75 |
76 | list.addItem('ContextMenuListItem', {
77 | caption: 'Actions',
78 | items: [{caption: 'Edit', action: 'editItem'}]
79 | });
80 |
81 | },
82 |
83 | setupImageEdit({edit}) {
84 |
85 | this.addToIndexControl().addSaveControl();
86 |
87 | edit.addField('MediaPreviewFormElement', {
88 | label: 'Photography',
89 | name: 'mediaPreview'
90 | });
91 |
92 | edit.addField('TextFormElement', {
93 | label: 'Title',
94 | name: 'title',
95 | attributes: {input: {class: 'inputType2 size2'}}
96 | });
97 |
98 | edit.addField('TextFormElement', {
99 | label: 'Caption',
100 | name: 'caption'
101 | });
102 |
103 | },
104 |
105 | setupVideoEmbedEdit({edit}) {
106 |
107 | this.addToIndexControl().addSaveControl();
108 |
109 | edit.addField('TextFormElement', {
110 | label: 'Title',
111 | name: 'title'
112 | });
113 |
114 | edit.addField('TextareaFormElement', {
115 | label: 'Embed code',
116 | name: 'embedCode'
117 | });
118 |
119 | },
120 |
121 | setupFileEdit({edit}) {
122 |
123 | this.addToIndexControl().addSaveControl();
124 |
125 | edit.addField('MediaPreviewFormElement', {
126 | label: 'Photography',
127 | name: 'mediaPreview'
128 | });
129 |
130 | edit.addField('TextFormElement', {
131 | label: 'Title',
132 | name: 'title',
133 | attributes: {input: {class: 'inputType2 size2'}}
134 | });
135 |
136 | }
137 |
138 | };
139 |
--------------------------------------------------------------------------------
/documentation/adding-resource.md:
--------------------------------------------------------------------------------
1 | # Adding resource
2 | We will examine typical scenario where new resource is ready on backend API and admin user interface has to be created.
3 | Steps needed to complete UI for this new resource:
4 | - examine resource API
5 | - create resource controller
6 | - add resource route
7 | - add navigation link
8 | - register controller as service
9 |
10 | For simple resources this can be completed in less then 5 minutes.
11 |
12 | ## Examine resource API
13 | Make sure that resource backend API is ready to handle get, post, and put requests.
14 | Check that backend properly outputs relation includes, make sure that filtering and validation rules are respected.
15 | Examine new resource attributes and relations and decide what list and form elements have to be used.
16 |
17 | Everything works? Then lets build resource UI controller.
18 |
19 | ## Create resource controller
20 | Let’s say new resource in need of UI is `tag` entity.
21 | We will need a component to describe how resource is browsed, filtered and sorted in list, what form fields are rendered when resource is created or updated.
22 |
23 | For this purpose we will build a tag resource controller in 'src/controllers/tag.js' file:
24 |
25 | ```js
26 | export default {
27 |
28 | resourceName: 'tag',
29 |
30 | setupList: function({list}) {
31 |
32 | this.addCreateControl('Create new tag');
33 |
34 | // --------------------------------------------------------------
35 | // Filters
36 | // --------------------------------------------------------------
37 | list.addFilter('TextFormElement', {
38 | name: 'title',
39 | label: 'Title'
40 | });
41 |
42 | // --------------------------------------------------------------
43 | // List items
44 | // --------------------------------------------------------------
45 | list.addItem('TextListItem', {
46 | attributes: {class: 'textListItemType1 hiddenOnMobile'},
47 | caption: 'ID',
48 | mapTo: 'id'
49 | });
50 |
51 | list.addItem('LinkListItem', {
52 | caption: 'Title',
53 | mapTo: 'title',
54 | action: 'editItem'
55 | });
56 |
57 | // --------------------------------------------------------------
58 | // Context menu
59 | // --------------------------------------------------------------
60 | list.addItem('ContextMenuListItem', {
61 | caption: 'Actions',
62 | items: [{caption: 'Edit', action: 'editItem'}]
63 | });
64 |
65 | },
66 |
67 | setupEdit: function({edit}) {
68 |
69 | this.addToIndexControl().addSaveControl();
70 |
71 | edit.addField('TextFormElement', {
72 | label: 'Title',
73 | name: 'title'
74 | });
75 |
76 | }
77 |
78 | };
79 | ```
80 | ---
81 |
82 | Read up on how [resource controllers](/core-concepts-and-api.html#resource-controller) work, examine how to build [resource list](/list-elements.html) and [form elements](/form-elements.html).
83 |
84 | ## Add resource route
85 | Open your routes file (src/routes.js) and add new resource route:
86 | ```js
87 | ...
88 | router.resource('tag');
89 | ...
90 | ```
91 | Browse [router docs](/core-concepts-and-api.html#router) to learn more.
92 |
93 |
94 | ## Add navigation item
95 | Open your main navigation component (src/mainNavigation.js) and add new navigation item:
96 | ```js
97 | getNavigationItems: router => [
98 |
99 | {
100 | caption: 'Tags',
101 | key: 'tag',
102 | url: router.url('resource.tag.index'),
103 | icon: 'Misc'
104 | }
105 |
106 | ],
107 | ```
108 | Read more about [main navigation](/core-concepts-and-api.html#navigation) if you want to learn more.
109 |
110 | ## Register controller as service
111 | Open up your services file (src/services.js) and add new tag resource controller to registry.
112 | Use dynamic import to load controller code and its dependencies only when controller is actually rendered.
113 | ```js
114 | ...
115 | TagController: () => import('./controllers/tag.js'),
116 | ...
117 | ```
118 | Done! Read up on [services](/core-concepts-and-api.html#services) if you want to learn more.
119 |
--------------------------------------------------------------------------------
/src/js/components/pagePreview.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
13 |
14 |
15 |
20 |
21 |
22 |
23 |
90 |
91 |
163 |
--------------------------------------------------------------------------------
/src/js/components/resourceListCards.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | -
4 |
5 |
6 |
13 |
14 |
15 |
22 |
23 |
24 |
25 |
26 |
27 |
45 |
46 |
145 |
--------------------------------------------------------------------------------
/src/js/components/resourceListTree.vue:
--------------------------------------------------------------------------------
1 |
2 |
74 |
75 |
76 |
113 |
--------------------------------------------------------------------------------
/src/js/components/message.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
20 |
21 |
22 |
23 |
93 |
94 |
205 |
--------------------------------------------------------------------------------