├── .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 | 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 | 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 | [![NPM Status](https://img.shields.io/npm/v/@trikoder/trim.svg)](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 | 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 | 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 | 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 | 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 | 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 | 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 | 28 | 29 | 56 | -------------------------------------------------------------------------------- /src/js/listElements/text.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 44 | 45 | 71 | -------------------------------------------------------------------------------- /src/js/listElements/dateTime.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 54 | 55 | 73 | -------------------------------------------------------------------------------- /src/js/formElements/htmlComponents/componentControls.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 37 | 38 | 65 | 66 | -------------------------------------------------------------------------------- /src/js/listElements/blip.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 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 | 11 | 12 | 58 | 59 | 90 | -------------------------------------------------------------------------------- /src/js/controllers/baseNestedResource.vue: -------------------------------------------------------------------------------- 1 | 76 | -------------------------------------------------------------------------------- /demo/controllers/article/quoteInContent.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 10 | 11 | 74 | 75 | 105 | -------------------------------------------------------------------------------- /src/js/formElements/checkbox.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 2 | 3 | -------------------------------------------------------------------------------- /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 | 12 | 13 | 71 | 72 | 105 | -------------------------------------------------------------------------------- /src/js/components/resourceSort.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 56 | 57 | 111 | -------------------------------------------------------------------------------- /src/js/formElements/code.vue: -------------------------------------------------------------------------------- 1 | 2 | 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 | 57 | 58 | 95 | -------------------------------------------------------------------------------- /src/js/components/loader.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 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 | 22 | 23 | 90 | 91 | 163 | -------------------------------------------------------------------------------- /src/js/components/resourceListCards.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 45 | 46 | 145 | -------------------------------------------------------------------------------- /src/js/components/resourceListTree.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 113 | -------------------------------------------------------------------------------- /src/js/components/message.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 93 | 94 | 205 | --------------------------------------------------------------------------------