├── .browserslistrc ├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── angular.json ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── app │ ├── app.component.html │ ├── app.component.ts │ ├── app.module.ts │ └── editor │ │ ├── core │ │ ├── ajax.ts │ │ ├── dataurl-to-blob-converter.spec.ts │ │ ├── dataurl-to-blob-converter.ts │ │ └── file-loader.ts │ │ ├── editor.component.html │ │ ├── editor.component.spec.ts │ │ ├── editor.component.ts │ │ ├── editor.module.ts │ │ ├── popups │ │ ├── confirm │ │ │ ├── confirm-popup-mode.ts │ │ │ ├── confirm-popup.component.html │ │ │ ├── confirm-popup.component.ts │ │ │ └── confirm-popup.service.ts │ │ ├── export │ │ │ ├── export-popup-mode.ts │ │ │ ├── export-popup.component.html │ │ │ ├── export-popup.component.ts │ │ │ ├── export-popup.service.ts │ │ │ └── generators │ │ │ │ ├── data-zip-generator.ts │ │ │ │ ├── relase-zip-generator.ts │ │ │ │ ├── template-zip-generator.ts │ │ │ │ └── zip-utils.ts │ │ ├── html-editor │ │ │ ├── html-editor-popup.component.html │ │ │ ├── html-editor-popup.component.ts │ │ │ └── html-editor-popup.service.ts │ │ ├── image-picker │ │ │ ├── image-picker-popup.component.html │ │ │ ├── image-picker-popup.component.ts │ │ │ └── image-picker-popup.service.ts │ │ ├── import │ │ │ ├── import-popup-mode.ts │ │ │ ├── import-popup.component.html │ │ │ ├── import-popup.component.ts │ │ │ ├── import-popup.service.ts │ │ │ └── importers │ │ │ │ ├── data-importer.ts │ │ │ │ ├── template-importer.ts │ │ │ │ └── zip-utils.ts │ │ ├── loader │ │ │ ├── loader-popup.component.html │ │ │ ├── loader-popup.component.ts │ │ │ ├── loader-popup.service.ts │ │ │ └── remote-template-loader.ts │ │ ├── markdown-editor │ │ │ ├── insert-at-cursor.ts │ │ │ ├── markdown-editor-popup.component.html │ │ │ ├── markdown-editor-popup.component.ts │ │ │ └── markdown-editor-popup.service.ts │ │ ├── popup.module.ts │ │ ├── popup.service.ts │ │ ├── scroll-down.directive.ts │ │ ├── template-info │ │ │ ├── template-info-popup.component.html │ │ │ ├── template-info-popup.component.ts │ │ │ └── template-info-popup.service.ts │ │ └── uploader │ │ │ ├── template-data-uploader.ts │ │ │ ├── uploader-popup.component.html │ │ │ ├── uploader-popup.component.ts │ │ │ └── uploader-popup.service.ts │ │ ├── preview │ │ ├── data-preview-renderer.ts │ │ ├── page-tabs.component.html │ │ ├── page-tabs.component.ts │ │ ├── preview-utils.ts │ │ ├── preview.component.html │ │ ├── preview.component.ts │ │ ├── preview.module.ts │ │ └── template-preview-renderer.ts │ │ ├── sidebar │ │ ├── configuration │ │ │ ├── configuration.component.html │ │ │ └── configuration.component.ts │ │ ├── dropdown.directive.spec.ts │ │ ├── dropdown.directive.ts │ │ ├── properties │ │ │ ├── boolean-property.component.html │ │ │ ├── boolean-property.component.ts │ │ │ ├── choice-property.component.html │ │ │ ├── choice-property.component.ts │ │ │ ├── collection-property.component.html │ │ │ ├── collection-property.component.spec.ts │ │ │ ├── collection-property.component.ts │ │ │ ├── color-property.component.html │ │ │ ├── color-property.component.ts │ │ │ ├── datetime-property.component.html │ │ │ ├── datetime-property.component.ts │ │ │ ├── html-property.component.html │ │ │ ├── html-property.component.ts │ │ │ ├── image-property.component.html │ │ │ ├── image-property.component.ts │ │ │ ├── markdown-property.component.html │ │ │ ├── markdown-property.component.ts │ │ │ ├── properties.component.html │ │ │ ├── properties.component.ts │ │ │ ├── text-property.component.html │ │ │ └── text-property.component.ts │ │ ├── sections.component.html │ │ ├── sections.component.ts │ │ ├── sidebar-menu.component.html │ │ ├── sidebar-menu.component.ts │ │ ├── sidebar.component.html │ │ ├── sidebar.component.spec.ts │ │ ├── sidebar.component.ts │ │ └── sidebar.module.ts │ │ ├── state.service.spec.ts │ │ ├── state.service.ts │ │ └── template-source.ts ├── assets │ ├── i18n │ │ └── en.json │ └── og-image.png ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.scss ├── templates │ └── boilerplate │ │ ├── assets │ │ ├── favicon.png │ │ ├── og-image.png │ │ └── style.css │ │ ├── footer.partial │ │ ├── header.partial │ │ ├── index.html │ │ ├── license.txt │ │ ├── page.html │ │ └── template.yaml └── test.ts ├── t3mpl-editor.png ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── tslint.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | tab_width = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.ts] 11 | quote_type = single 12 | 13 | [*.md] 14 | indent_style = space 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /tmp 4 | /out-tsc 5 | # Only exists if Bazel was run 6 | /bazel-out 7 | 8 | # dependencies 9 | node_modules/ 10 | 11 | # profiling files 12 | chrome-profiler-events*.json 13 | speed-measure-plugin*.json 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # misc 33 | /.sass-cache 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | npm-debug.log 38 | yarn-error.log 39 | testem.log 40 | /typings 41 | debug.log 42 | 43 | # System Files 44 | .DS_Store 45 | Thumbs.db 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "12" 5 | - "14" 6 | 7 | install: 8 | - npm install 9 | 10 | script: 11 | - npm run test:single 12 | - npm run lint 13 | - npm run build 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [0.4.0] - 2020-11-19 4 | 5 | * Supporting absolute urls. The configuration has `baseUrl` parameter now. 6 | 7 | ### [0.3.0] - 2020-11-16 8 | 9 | * Markdown supports images attaching. 10 | * The editor removes not used files from data. 11 | 12 | ### [0.2.2] - 2020-10-31 13 | 14 | * The editor supports the two-level collection now. 15 | 16 | ### [0.2.1] - 2020-10-29 17 | 18 | * One new strategy to generate website files. You can change this strategy in the editor. Find the new "Configuration" section in the sidebar on the left. Current T3MPL supports two strategies: 19 | * **Absolute Path Strategy (default)** - Anchors on the page have direct paths to files, i.e. ``. 20 | * **Directory Path Strategy (new)** - Every page is generated to own directory to the index.html file. Anchors on the page have the path to the directory only (without index.html), i.e. ``. That strategy should work on the most popular servers like Apache, Nginx and IIS. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Bartlomiej Tadych 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![T3MPL Editor](t3mpl-editor.png) 2 | 3 | # T3MPL Editor 4 | 5 | [![Build Status](https://travis-ci.com/b4rtaz/t3mpl-editor.svg?branch=master)](https://travis-ci.com/b4rtaz/t3mpl-editor) [![License: MIT](https://img.shields.io/github/license/mashape/apistatus.svg)](/LICENSE) [![Twitter: b4rtaz](https://img.shields.io/twitter/follow/b4rtaz.svg?style=social)](https://twitter.com/b4rtaz) 6 | 7 | T3MPL is the generic website editor and the static website generator in one. To generate a website you need just a browser (or nodejs). Choose a website template and edit it by T3MPL Editor. In 3 minutes you will get a final website zip file. You need only upload generated files to your server. Moreover, T3MPL is totally free and open source. 8 | 9 | * 🍕 [Open T3MPL Editor](https://t3mpl.n4no.com/editor/#manifest=../templates/t3mpl-one/template.yaml) 10 | * ☕ [Browse T3MPL Templates](https://t3mpl.n4no.com/) 11 | 12 | If you want to use T3MPL from command line, check [T3MPL Core](https://github.com/b4rtaz/t3mpl-core) repository. 13 | 14 | All available templates on project website comes from [T3MPL Templates](https://github.com/b4rtaz/t3mpl-templates) repository. 15 | 16 | ## ⚙️ How to Run 17 | 18 | [Node.js](https://nodejs.org/en/) is required. 19 | 20 | #### Development 21 | 22 | ``` 23 | npm install 24 | npm run start 25 | ``` 26 | 27 | To open a template from `src/templates/` directory add `#manifest=path` to URL. 28 | 29 | `http://localhost:4200/#manifest=templates/boilerplate/template.yaml` 30 | 31 | #### Build 32 | 33 | ``` 34 | npm install 35 | npm run build 36 | ``` 37 | 38 | ## 🤝 Contributing 39 | 40 | Contributions, issues and feature requests are welcome! 41 | 42 | ## 💡 License 43 | 44 | This project is released under the MIT license. 45 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "t3mpl-editor": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/t3mpl-editor", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "aot": true, 26 | "assets": [ 27 | "src/favicon.ico", 28 | "src/assets", 29 | "src/templates" 30 | ], 31 | "styles": [ 32 | "src/styles.scss", 33 | "node_modules/@fortawesome/fontawesome-free/css/fontawesome.css", 34 | "node_modules/@fortawesome/fontawesome-free/css/solid.css" 35 | ], 36 | "scripts": [], 37 | "allowedCommonJsDependencies": [ 38 | "t3mpl-core", 39 | "file-saver", 40 | "he" 41 | ] 42 | }, 43 | "configurations": { 44 | "production": { 45 | "fileReplacements": [ 46 | { 47 | "replace": "src/environments/environment.ts", 48 | "with": "src/environments/environment.prod.ts" 49 | } 50 | ], 51 | "optimization": true, 52 | "outputHashing": "all", 53 | "sourceMap": false, 54 | "extractCss": true, 55 | "namedChunks": false, 56 | "extractLicenses": true, 57 | "vendorChunk": false, 58 | "buildOptimizer": true, 59 | "budgets": [ 60 | { 61 | "type": "initial", 62 | "maximumWarning": "2mb", 63 | "maximumError": "5mb" 64 | }, 65 | { 66 | "type": "anyComponentStyle", 67 | "maximumWarning": "6kb", 68 | "maximumError": "10kb" 69 | } 70 | ] 71 | } 72 | } 73 | }, 74 | "serve": { 75 | "builder": "@angular-devkit/build-angular:dev-server", 76 | "options": { 77 | "browserTarget": "t3mpl-editor:build" 78 | }, 79 | "configurations": { 80 | "production": { 81 | "browserTarget": "t3mpl-editor:build:production" 82 | } 83 | } 84 | }, 85 | "extract-i18n": { 86 | "builder": "@angular-devkit/build-angular:extract-i18n", 87 | "options": { 88 | "browserTarget": "t3mpl-editor:build" 89 | } 90 | }, 91 | "test": { 92 | "builder": "@angular-devkit/build-angular:karma", 93 | "options": { 94 | "main": "src/test.ts", 95 | "polyfills": "src/polyfills.ts", 96 | "tsConfig": "tsconfig.spec.json", 97 | "karmaConfig": "karma.conf.js", 98 | "assets": [ 99 | "src/favicon.ico", 100 | "src/assets" 101 | ], 102 | "styles": [ 103 | "src/styles.scss" 104 | ], 105 | "scripts": [] 106 | } 107 | }, 108 | "lint": { 109 | "builder": "@angular-devkit/build-angular:tslint", 110 | "options": { 111 | "tsConfig": [ 112 | "tsconfig.app.json", 113 | "tsconfig.spec.json", 114 | "e2e/tsconfig.json" 115 | ], 116 | "exclude": [ 117 | "**/node_modules/**" 118 | ] 119 | } 120 | }, 121 | "e2e": { 122 | "builder": "@angular-devkit/build-angular:protractor", 123 | "options": { 124 | "protractorConfig": "e2e/protractor.conf.js", 125 | "devServerTarget": "t3mpl-editor:serve" 126 | }, 127 | "configurations": { 128 | "production": { 129 | "devServerTarget": "t3mpl-editor:serve:production" 130 | } 131 | } 132 | } 133 | } 134 | }}, 135 | "defaultProject": "t3mpl-editor" 136 | } 137 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ 31 | spec: { 32 | displayStacktrace: StacktraceOption.PRETTY 33 | } 34 | })); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('t3mpl-editor app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText() { 9 | return element(by.css('app-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es2018", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/t3mpl-editor'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "t3mpl-editor", 3 | "version": "0.4.1", 4 | "author": "Bartlomiej Tadych (http://n4no.com)", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve", 8 | "build": "ng build", 9 | "build:watch": "ng build --watch", 10 | "build:prod": "ng build --prod=true", 11 | "test": "ng test", 12 | "test:single": "ng test --watch=false --no-progress --browsers=ChromeHeadless", 13 | "lint": "ng lint", 14 | "e2e": "ng e2e" 15 | }, 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/b4rtaz/t3mpl-editor.git" 20 | }, 21 | "dependencies": { 22 | "@angular/common": "^10.2.2", 23 | "@angular/compiler": "^10.2.2", 24 | "@angular/core": "^10.2.2", 25 | "@angular/platform-browser": "^10.2.2", 26 | "@angular/platform-browser-dynamic": "^10.2.2", 27 | "@fortawesome/fontawesome-free": "^5.14.0", 28 | "@ngx-translate/core": "^13.0.0", 29 | "@ngx-translate/http-loader": "^6.0.0", 30 | "dayjs": "^1.9.5", 31 | "file-saver": "^2.0.2", 32 | "jszip": "^3.5.0", 33 | "rxjs": "~6.6.0", 34 | "t3mpl-core": "^0.4.1", 35 | "tslib": "^2.0.0", 36 | "zone.js": "~0.10.2" 37 | }, 38 | "devDependencies": { 39 | "@angular-devkit/build-angular": "~0.1002.0", 40 | "@angular/cli": "~10.2.0", 41 | "@angular/compiler-cli": "^10.2.2", 42 | "@types/file-saver": "^2.0.1", 43 | "@types/jasmine": "~3.5.0", 44 | "@types/jasminewd2": "~2.0.3", 45 | "@types/jszip": "^3.4.1", 46 | "@types/node": "^12.19.3", 47 | "codelyzer": "^6.0.0", 48 | "jasmine-core": "~3.6.0", 49 | "jasmine-spec-reporter": "~5.0.0", 50 | "karma": "~5.0.0", 51 | "karma-chrome-launcher": "~3.1.0", 52 | "karma-coverage-istanbul-reporter": "~3.0.2", 53 | "karma-jasmine": "~4.0.0", 54 | "karma-jasmine-html-reporter": "^1.5.0", 55 | "protractor": "~7.0.0", 56 | "ts-node": "~8.3.0", 57 | "tslint": "~6.1.0", 58 | "typescript": "~4.0.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { Component, HostListener, Inject, OnInit } from '@angular/core'; 3 | import { TranslateService } from '@ngx-translate/core'; 4 | 5 | import { TemplateSource } from './editor/template-source'; 6 | 7 | @Component({ 8 | selector: 'app-root', 9 | templateUrl: './app.component.html' 10 | }) 11 | export class AppComponent implements OnInit { 12 | 13 | public templateSource: TemplateSource; 14 | 15 | public constructor( 16 | @Inject(DOCUMENT) private readonly document: Document, 17 | private readonly translate: TranslateService) { 18 | } 19 | 20 | public ngOnInit() { 21 | this.translate.use('en').subscribe(() => { 22 | this.tryReadHash(); 23 | }); 24 | } 25 | 26 | @HostListener('window:hashchange') 27 | public onHashChanged() { 28 | this.tryReadHash(); 29 | } 30 | 31 | private tryReadHash() { 32 | const hash = this.document.location.hash; 33 | if (hash) { 34 | const params = new URLSearchParams(hash.substring(1)); 35 | 36 | if (params.has('manifest')) { 37 | this.templateSource = { 38 | type: 'remoteTemplate', 39 | manifestUrl: params.get('manifest') 40 | }; 41 | } else if (params.has('website')) { 42 | this.templateSource = { 43 | type: 'server', 44 | websiteUrl: params.get('website') 45 | }; 46 | } else { 47 | throw new Error('Not supported hash.'); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpClientModule } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; 5 | import { TranslateHttpLoader } from '@ngx-translate/http-loader'; 6 | 7 | import { AppComponent } from './app.component'; 8 | import { EditorModule } from './editor/editor.module'; 9 | 10 | export function HttpLoaderFactory(httpClient: HttpClient) { 11 | return new TranslateHttpLoader(httpClient, 'assets/i18n/'); 12 | } 13 | 14 | @NgModule({ 15 | declarations: [ 16 | AppComponent 17 | ], 18 | imports: [ 19 | BrowserModule, 20 | EditorModule, 21 | HttpClientModule, 22 | TranslateModule.forRoot({ 23 | loader: { 24 | provide: TranslateLoader, 25 | useFactory: HttpLoaderFactory, 26 | deps: [HttpClient] 27 | }, 28 | defaultLanguage: 'en' 29 | }) 30 | ], 31 | providers: [], 32 | bootstrap: [AppComponent] 33 | }) 34 | export class AppModule { 35 | } 36 | -------------------------------------------------------------------------------- /src/app/editor/core/ajax.ts: -------------------------------------------------------------------------------- 1 | 2 | export type HttpMethod = 'GET' | 'POST' | 'PUT'; 3 | 4 | export interface AjaxSettings { 5 | method: HttpMethod; 6 | url: string; 7 | responseType: XMLHttpRequestResponseType; 8 | timeout?: number; 9 | contentType?: string; 10 | file?: Blob | string; 11 | } 12 | 13 | export function ajax(s: AjaxSettings): Promise { 14 | return new Promise((resolve, reject) => { 15 | const xhr = new XMLHttpRequest(); 16 | xhr.responseType = s.responseType; 17 | xhr.timeout = s.timeout; 18 | xhr.onreadystatechange = () => { 19 | if (xhr.readyState === 4) { 20 | if (xhr.status === 200) { 21 | resolve(xhr.response as T); 22 | } else { 23 | reject(`Invalid response code: ${xhr.status}`); 24 | } 25 | } 26 | }; 27 | xhr.onerror = (e) => reject(e); 28 | xhr.open(s.method, s.url); 29 | if (s.contentType) { 30 | xhr.setRequestHeader('Content-Type', s.contentType); 31 | } 32 | if (s.file) { 33 | xhr.send(s.file); 34 | } else { 35 | xhr.send(); 36 | } 37 | }); 38 | } 39 | 40 | export function ajaxAsText(method: HttpMethod, url: string): Promise { 41 | return ajax({ 42 | method, 43 | url, 44 | responseType: 'text' 45 | }); 46 | } 47 | 48 | export function ajaxAsBase64(method: HttpMethod, url: string): Promise { 49 | return new Promise((resolve, reject) => { 50 | ajax({ 51 | method, 52 | url, 53 | responseType: 'blob' 54 | }).then(data => { 55 | const reader = new FileReader(); 56 | reader.onloadend = () => { 57 | resolve(reader.result as string); 58 | }; 59 | reader.onerror = (e) => { 60 | reject(e); 61 | }; 62 | reader.readAsDataURL(data as Blob); 63 | }, e => reject(e)); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/editor/core/dataurl-to-blob-converter.spec.ts: -------------------------------------------------------------------------------- 1 | import { convertDataUrlToBlob } from './dataurl-to-blob-converter'; 2 | 3 | describe('convertDataUrlToBlob()', () => { 4 | 5 | it('convertDataUrlToBlob() returns proper value', async () => { 6 | const blob = convertDataUrlToBlob('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='); 7 | expect(await blob.text()).toEqual('Hello, World!'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/editor/core/dataurl-to-blob-converter.ts: -------------------------------------------------------------------------------- 1 | 2 | export function convertDataUrlToBlob(dataUrl: string): Blob { 3 | const pos = dataUrl.indexOf(','); 4 | const bytes = atob(dataUrl.substring(pos + 1)); 5 | 6 | const ab = new ArrayBuffer(bytes.length); 7 | const ia = new Uint8Array(ab); 8 | for (let i = 0; i < bytes.length; i++) { 9 | ia[i] = bytes.charCodeAt(i); 10 | } 11 | return new Blob([ab]); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/editor/core/file-loader.ts: -------------------------------------------------------------------------------- 1 | 2 | export class FileLoader { 3 | 4 | public static loadAsDataURL(file: File): Promise { 5 | return new Promise((resolve, reject) => { 6 | const reader = new FileReader(); 7 | reader.onload = () => { 8 | resolve(reader.result as string); 9 | }; 10 | reader.onerror = e => reject(e); 11 | reader.readAsDataURL(file); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/editor/editor.component.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | 7 | 8 |
9 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /src/app/editor/editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { readTitleSuffix } from './editor.component'; 2 | 3 | describe('EditorComponent', () => { 4 | 5 | it('readTitleSuffix() returns proper value', () => { 6 | expect(readTitleSuffix('alfa - dot.com', ' - ')).toEqual('dot.com'); 7 | expect(readTitleSuffix('alfa - beta - bar.com', ' - ')).toEqual('bar.com'); 8 | expect(readTitleSuffix('foo.org', ' - ')).toEqual('foo.org'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/app/editor/editor.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | 4 | import { LoaderPopupService } from './popups/loader/loader-popup.service'; 5 | import { PopupService } from './popups/popup.service'; 6 | import { StateService } from './state.service'; 7 | import { TemplateSource } from './template-source'; 8 | 9 | @Component({ 10 | selector: 'app-editor', 11 | templateUrl: './editor.component.html' 12 | }) 13 | export class EditorComponent implements OnInit, OnChanges { 14 | 15 | @Input() 16 | public templateSource: TemplateSource; 17 | 18 | @ViewChild('container', { static: true, read: ViewContainerRef }) 19 | public container: ViewContainerRef; 20 | 21 | public constructor( 22 | private readonly popupService: PopupService, 23 | private readonly stateService: StateService, 24 | private readonly titleService: Title, 25 | private readonly loaderPopup: LoaderPopupService) { 26 | } 27 | 28 | public ngOnInit() { 29 | this.popupService.setContainer(this.container); 30 | this.stateService.onStateChanged.subscribe(() => this.onStateChanged()); 31 | 32 | if (this.templateSource) { 33 | this.load(); 34 | } 35 | } 36 | 37 | public ngOnChanges(changes: SimpleChanges) { 38 | if (changes.templateSource && !changes.templateSource.firstChange) { 39 | this.load(); 40 | } 41 | } 42 | 43 | private onStateChanged() { 44 | this.reloadPageTitle(); 45 | } 46 | 47 | private load() { 48 | this.loaderPopup.load(this.templateSource); 49 | } 50 | 51 | private reloadPageTitle() { 52 | const templateName = this.stateService.templateManifest.meta.name; 53 | const separator = ' - '; 54 | const suffix = readTitleSuffix(this.titleService.getTitle(), separator); 55 | this.titleService.setTitle(templateName + separator + suffix); 56 | } 57 | } 58 | 59 | export function readTitleSuffix(title: string, separator: string): string { 60 | const p = title.lastIndexOf(separator); 61 | return p > 0 ? title.substring(p + separator.length) : title; 62 | } 63 | -------------------------------------------------------------------------------- /src/app/editor/editor.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | 5 | import { EditorComponent } from './editor.component'; 6 | import { PopupModule } from './popups/popup.module'; 7 | import { PreviewModule } from './preview/preview.module'; 8 | import { SidebarModule } from './sidebar/sidebar.module'; 9 | import { StateService } from './state.service'; 10 | 11 | @NgModule({ 12 | declarations: [ 13 | EditorComponent, 14 | ], 15 | imports: [ 16 | BrowserModule, 17 | CommonModule, 18 | SidebarModule, 19 | PopupModule, 20 | PreviewModule 21 | ], 22 | providers: [ 23 | StateService 24 | ], 25 | exports: [ 26 | EditorComponent 27 | ] 28 | }) 29 | export class EditorModule { 30 | } 31 | -------------------------------------------------------------------------------- /src/app/editor/popups/confirm/confirm-popup-mode.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum ConfirmPopupMode { 3 | ok, 4 | okCancel 5 | } 6 | -------------------------------------------------------------------------------- /src/app/editor/popups/confirm/confirm-popup.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/editor/popups/confirm/confirm-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | 4 | import { PopupComponent } from '../popup.service'; 5 | import { ConfirmPopupMode } from './confirm-popup-mode'; 6 | 7 | @Component({ 8 | templateUrl: './confirm-popup.component.html' 9 | }) 10 | export class ConfirmPopupComponent implements OnInit, PopupComponent { 11 | 12 | public readonly result: Subject = new Subject(); 13 | 14 | @Input() 15 | public mode: ConfirmPopupMode; 16 | @Input() 17 | public title: string; 18 | @Input() 19 | public message: string; 20 | 21 | public isCancelVisible: boolean; 22 | 23 | public ngOnInit() { 24 | this.isCancelVisible = this.mode === ConfirmPopupMode.okCancel; 25 | } 26 | 27 | public close(result: boolean) { 28 | this.result.next(result); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/editor/popups/confirm/confirm-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { PopupService } from '../popup.service'; 5 | import { ConfirmPopupMode } from './confirm-popup-mode'; 6 | import { ConfirmPopupComponent } from './confirm-popup.component'; 7 | 8 | @Injectable() 9 | export class ConfirmPopupService { 10 | 11 | public constructor( 12 | private readonly popupService: PopupService) { 13 | } 14 | 15 | public ok(title: string, message: string) { 16 | this.show(ConfirmPopupMode.ok, title, message) 17 | .subscribe(() => {}); 18 | } 19 | 20 | public prompt(title: string, message: string): Observable { 21 | return this.show(ConfirmPopupMode.okCancel, title, message); 22 | } 23 | 24 | private show(mode: ConfirmPopupMode, title: string, message: string): Observable { 25 | return this.popupService.open(ConfirmPopupComponent, i => { 26 | i.mode = mode; 27 | i.title = title; 28 | i.message = message; 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/export-popup-mode.ts: -------------------------------------------------------------------------------- 1 | 2 | export type ExportPopupMode = 'publish' | 'template' | 'data'; 3 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/export-popup.component.html: -------------------------------------------------------------------------------- 1 |
62 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/export-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import * as fileSaver from 'file-saver'; 3 | import { Observable, Subject } from 'rxjs'; 4 | import { PROJECT_DONATE_URL } from 't3mpl-core/core/constants'; 5 | import { TemplateManifestMeta } from 't3mpl-core/core/model'; 6 | 7 | import { StateService } from '../../state.service'; 8 | import { PopupComponent } from '../popup.service'; 9 | import { ExportPopupMode } from './export-popup-mode'; 10 | import { DataZipGenerator } from './generators/data-zip-generator'; 11 | import { ReleaseZipGenerator } from './generators/relase-zip-generator'; 12 | import { TemplateZipGenerator } from './generators/template-zip-generator'; 13 | import { ZipFile } from './generators/zip-utils'; 14 | 15 | @Component({ 16 | templateUrl: './export-popup.component.html' 17 | }) 18 | export class ExportPopupComponent implements OnInit, PopupComponent { 19 | 20 | public readonly result: Subject = new Subject(); 21 | 22 | @Input() 23 | public mode: ExportPopupMode; 24 | 25 | public templateMeta: TemplateManifestMeta; 26 | public projectDonateUrl: string; 27 | 28 | public processing = false; 29 | 30 | public constructor( 31 | private readonly stateService: StateService, 32 | private readonly releaseZipGenerator: ReleaseZipGenerator, 33 | private readonly dataZipGenerator: DataZipGenerator, 34 | private readonly templateZipGenerator: TemplateZipGenerator) { 35 | } 36 | 37 | public ngOnInit() { 38 | this.templateMeta = this.stateService.templateManifest.meta; 39 | this.projectDonateUrl = PROJECT_DONATE_URL; 40 | } 41 | 42 | public save() { 43 | if (!this.processing) { 44 | let res: Observable; 45 | 46 | switch (this.mode) { 47 | case 'publish': 48 | res = this.releaseZipGenerator.generate(); 49 | break; 50 | 51 | case 'data': 52 | res = this.dataZipGenerator.generate(); 53 | break; 54 | 55 | case 'template': 56 | res = this.templateZipGenerator.generate(); 57 | break; 58 | 59 | default: 60 | throw new Error(`Not supported mode ${this.mode}.`); 61 | } 62 | 63 | this.processing = true; 64 | res.subscribe(r => { 65 | this.processing = false; 66 | fileSaver.saveAs(r.content, r.fileName); 67 | this._close(); 68 | }, (e) => { 69 | console.error(e); 70 | alert(e); 71 | this.processing = false; 72 | }); 73 | } 74 | } 75 | 76 | public close() { 77 | if (!this.processing) { 78 | this._close(); 79 | } 80 | } 81 | 82 | private _close() { 83 | this.result.next(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/export-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { PopupService } from '../popup.service'; 4 | import { ExportPopupMode } from './export-popup-mode'; 5 | import { ExportPopupComponent } from './export-popup.component'; 6 | 7 | @Injectable() 8 | export class ExportPopupService { 9 | 10 | public constructor( 11 | private readonly popupService: PopupService) { 12 | } 13 | 14 | public open(mode: ExportPopupMode) { 15 | this.popupService.open(ExportPopupComponent, i => { 16 | i.mode = mode; 17 | }).subscribe(() => {}); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/generators/data-zip-generator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import * as JSZip from 'jszip'; 3 | import { Observable } from 'rxjs'; 4 | import { DATA_ZIP_FILE_EXT } from 't3mpl-core/core/constants'; 5 | import { DataSerializer } from 't3mpl-core/core/data/data-serializer'; 6 | import { Exporter } from 't3mpl-core/core/exporter'; 7 | import { UsedFilesScanner } from 't3mpl-core/core/scanners/used-files-scanner'; 8 | import { generateFileName } from 't3mpl-core/core/utils/file-name-generator'; 9 | 10 | import { StateService } from '../../../state.service'; 11 | import { compress, zipExportHandler, ZipFile } from './zip-utils'; 12 | 13 | @Injectable() 14 | export class DataZipGenerator { 15 | 16 | public constructor( 17 | private readonly stateService: StateService) { 18 | } 19 | 20 | public generate(): Observable { 21 | return new Observable(r => { 22 | const zip = new JSZip(); 23 | 24 | const dataSerializer = new DataSerializer(); 25 | const usedFileScanner = new UsedFilesScanner(this.stateService.contentStorage); 26 | 27 | Exporter.exportData( 28 | this.stateService.templateManifest, 29 | this.stateService.templateData, 30 | this.stateService.contentStorage, 31 | dataSerializer, 32 | usedFileScanner, 33 | zipExportHandler(zip)); 34 | 35 | compress(zip).then((content) => { 36 | const fileName = generateFileName({ 37 | name: this.stateService.templateManifest.meta.name, 38 | minUniqueIdLength: 4, 39 | fileExt: DATA_ZIP_FILE_EXT 40 | }); 41 | 42 | r.next({ content, fileName }); 43 | }); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/generators/relase-zip-generator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import * as JSZip from 'jszip'; 3 | import { Observable } from 'rxjs'; 4 | import { PagesDataGenerator } from 't3mpl-core/core/data/pages-data-generator'; 5 | import { Exporter } from 't3mpl-core/core/exporter'; 6 | import { PagesResolver } from 't3mpl-core/core/pages-resolver'; 7 | import { TemplateRenderer } from 't3mpl-core/core/renderer/template-renderer'; 8 | import { UsedFilesScanner } from 't3mpl-core/core/scanners/used-files-scanner'; 9 | import { generateFileName } from 't3mpl-core/core/utils/file-name-generator'; 10 | 11 | import { StateService } from '../../../state.service'; 12 | import { compress, zipExportHandler, ZipFile } from './zip-utils'; 13 | 14 | @Injectable() 15 | export class ReleaseZipGenerator { 16 | 17 | public constructor( 18 | private readonly stateService: StateService) { 19 | } 20 | 21 | public generate(): Observable { 22 | return new Observable(r => { 23 | const templateRenderer = new TemplateRenderer( 24 | false, 25 | this.stateService.templateStorage, 26 | this.stateService.contentStorage, 27 | new PagesDataGenerator()); 28 | 29 | const pagesResolver = new PagesResolver(this.stateService.templateData.configuration.pagePathStrategy); 30 | const usedFileScanner = new UsedFilesScanner(this.stateService.contentStorage); 31 | 32 | const zip = new JSZip(); 33 | Exporter.exportRelease( 34 | this.stateService.templateManifest, 35 | this.stateService.templateData, 36 | this.stateService.contentStorage, 37 | this.stateService.templateStorage, 38 | pagesResolver, 39 | templateRenderer, 40 | usedFileScanner, 41 | zipExportHandler(zip)); 42 | 43 | compress(zip).then((content) => { 44 | const fileName = generateFileName({ 45 | name: this.stateService.templateManifest.meta.name, 46 | minUniqueIdLength: 4, 47 | fileExt: '.zip' 48 | }); 49 | 50 | r.next({ content, fileName }); 51 | }); 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/generators/template-zip-generator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import * as JSZip from 'jszip'; 3 | import { Observable } from 'rxjs'; 4 | import { TEMPLATE_ZIP_FILE_EXT } from 't3mpl-core/core/constants'; 5 | import { Exporter } from 't3mpl-core/core/exporter'; 6 | import { generateFileName } from 't3mpl-core/core/utils/file-name-generator'; 7 | 8 | import { StateService } from '../../../state.service'; 9 | import { compress, zipExportHandler, ZipFile } from './zip-utils'; 10 | 11 | @Injectable() 12 | export class TemplateZipGenerator { 13 | 14 | public constructor( 15 | private readonly stateService: StateService) { 16 | } 17 | 18 | public generate(): Observable { 19 | return new Observable(r => { 20 | const zip = new JSZip(); 21 | 22 | Exporter.exportTemplate(this.stateService.templateStorage, zipExportHandler(zip)); 23 | 24 | compress(zip).then((content) => { 25 | const fileName = generateFileName({ 26 | name: this.stateService.templateManifest.meta.name, 27 | minUniqueIdLength: 4, 28 | fileExt: TEMPLATE_ZIP_FILE_EXT 29 | }); 30 | 31 | r.next({ content, fileName }); 32 | }); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/editor/popups/export/generators/zip-utils.ts: -------------------------------------------------------------------------------- 1 | import * as JSZip from 'jszip'; 2 | import { ExportHandler } from 't3mpl-core/core/exporter'; 3 | import { ContentType } from 't3mpl-core/core/storage'; 4 | 5 | import { convertDataUrlToBlob } from '../../../core/dataurl-to-blob-converter'; 6 | 7 | export function zipExportHandler(zip: JSZip): ExportHandler { 8 | return (filePath: string, contentType: ContentType, content) => { 9 | switch (contentType) { 10 | case 'text': 11 | zip.file(filePath, content, { binary: false }); 12 | break; 13 | 14 | case 'dataUrl': 15 | const binary = convertDataUrlToBlob(content); 16 | zip.file(filePath, binary, { binary: true }); 17 | break; 18 | 19 | default: 20 | throw new Error('Not supported content type.'); 21 | } 22 | }; 23 | } 24 | 25 | export function compress(zip: JSZip): Promise { 26 | return zip.generateAsync({ 27 | type: 'blob', 28 | compression: 'DEFLATE', 29 | mimeType: 'application/zip', 30 | compressionOptions: { 31 | level: 8 32 | } 33 | }); 34 | } 35 | 36 | export interface ZipFile { 37 | content: Blob; 38 | fileName: string; 39 | } 40 | -------------------------------------------------------------------------------- /src/app/editor/popups/html-editor/html-editor-popup.component.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /src/app/editor/popups/html-editor/html-editor-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | import { HTML_CONTENT_BASE_PATH, HTML_CONTENT_EXT } from 't3mpl-core/core/constants'; 4 | import { generateFileName } from 't3mpl-core/core/utils/file-name-generator'; 5 | 6 | import { StateService } from '../../state.service'; 7 | import { PopupComponent } from '../popup.service'; 8 | 9 | @Component({ 10 | templateUrl: './html-editor-popup.component.html' 11 | }) 12 | export class HtmlEditorPopupComponent implements OnInit, PopupComponent { 13 | 14 | public readonly result: Subject = new Subject(); 15 | 16 | @Input() 17 | public filePath: string; 18 | 19 | @ViewChild('textarea', { static: true }) 20 | public textarea: ElementRef; 21 | 22 | public constructor( 23 | private readonly stateService: StateService) { 24 | } 25 | 26 | public ngOnInit() { 27 | if (this.filePath) { 28 | this.textarea.nativeElement.value = this.stateService.contentStorage.getContent('text', this.filePath); 29 | } 30 | } 31 | 32 | public save() { 33 | const content = this.textarea.nativeElement.value; 34 | if (content) { 35 | if (!this.filePath) { 36 | this.filePath = HTML_CONTENT_BASE_PATH + generateFileName({ 37 | fileExt: HTML_CONTENT_EXT 38 | }); 39 | } 40 | 41 | this.stateService.contentStorage.setContent('text', this.filePath, content); 42 | this.result.next(this.filePath); 43 | } else { 44 | this.result.next(null); 45 | } 46 | } 47 | 48 | public close() { 49 | this.result.next(this.filePath); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/editor/popups/html-editor/html-editor-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { PopupService } from '../popup.service'; 5 | import { HtmlEditorPopupComponent } from './html-editor-popup.component'; 6 | 7 | @Injectable() 8 | export class HtmlEditorPopupService { 9 | 10 | public constructor( 11 | private readonly popupService: PopupService) { 12 | } 13 | 14 | public edit(filePath: string): Observable { 15 | return this.popupService.open(HtmlEditorPopupComponent, i => { 16 | i.filePath = filePath; 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/editor/popups/image-picker/image-picker-popup.component.html: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /src/app/editor/popups/image-picker/image-picker-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | import { IMAGE_CONTENT_BASE_PATH } from 't3mpl-core/core/constants'; 4 | import { generateFileName } from 't3mpl-core/core/utils/file-name-generator'; 5 | import { getFileExt } from 't3mpl-core/core/utils/path-utils'; 6 | 7 | import { StateService } from '../../state.service'; 8 | import { PopupComponent } from '../popup.service'; 9 | 10 | const MAX_SIZE = 200; 11 | 12 | @Component({ 13 | selector: 'app-image-picker-popup', 14 | templateUrl: './image-picker-popup.component.html' 15 | }) 16 | export class ImagePickerPopupComponent implements OnInit, PopupComponent { 17 | 18 | private context: CanvasRenderingContext2D; 19 | private newFilePath: string; 20 | 21 | public readonly result: Subject = new Subject(); 22 | 23 | @ViewChild('canvas', { static: true }) 24 | public canvas: ElementRef; 25 | 26 | public filePath: string; 27 | 28 | public constructor( 29 | private readonly stateService: StateService) { 30 | } 31 | 32 | public ngOnInit() { 33 | this.context = this.canvas.nativeElement.getContext('2d'); 34 | if (this.filePath) { 35 | this.reloadPreview(); 36 | } 37 | } 38 | 39 | public onChanged(event: Event) { 40 | const files = (event.target as any).files as File[]; 41 | if (files && files[0]) { 42 | const reader = new FileReader(); 43 | reader.onload = () => { 44 | const data = reader.result as string; 45 | const sourceFileName = files[0].name; 46 | this.newFilePath = IMAGE_CONTENT_BASE_PATH + generateFileName({ 47 | fileExt: getFileExt(sourceFileName) 48 | }); 49 | this.stateService.contentStorage.setContent('dataUrl', this.newFilePath, data); 50 | this.reloadPreview(); 51 | }; 52 | reader.readAsDataURL(files[0]); 53 | } 54 | } 55 | 56 | private reloadPreview() { 57 | const image = new Image(); 58 | image.onload = () => { 59 | const scale = Math.min( 60 | MAX_SIZE / image.width, 61 | MAX_SIZE / image.height 62 | ); 63 | const width = image.width * scale; 64 | const height = image.height * scale; 65 | 66 | this.canvas.nativeElement.width = width; 67 | this.canvas.nativeElement.height = height; 68 | this.context.drawImage(image, 0, 0, width, height); 69 | }; 70 | image.src = this.stateService.contentStorage.getContent('dataUrl', this.getCurrentFilePath()); 71 | } 72 | 73 | private getCurrentFilePath(): string { 74 | return this.newFilePath 75 | ? this.newFilePath 76 | : this.filePath; 77 | } 78 | 79 | public save() { 80 | this.result.next(this.getCurrentFilePath()); 81 | } 82 | 83 | public cancel() { 84 | if (this.newFilePath) { 85 | this.stateService.contentStorage.remove('dataUrl', this.newFilePath); 86 | } 87 | this.result.next(this.filePath); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/editor/popups/image-picker/image-picker-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { PopupService } from '../popup.service'; 5 | import { ImagePickerPopupComponent } from './image-picker-popup.component'; 6 | 7 | @Injectable() 8 | export class ImagePickerPopupService { 9 | 10 | public constructor( 11 | private readonly popupService: PopupService) { 12 | } 13 | 14 | public pick(filePath: string): Observable { 15 | return this.popupService.open(ImagePickerPopupComponent, i => { 16 | i.filePath = filePath; 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/editor/popups/import/import-popup-mode.ts: -------------------------------------------------------------------------------- 1 | 2 | export type ImportPopupMode = 'template' | 'data'; 3 | -------------------------------------------------------------------------------- /src/app/editor/popups/import/import-popup.component.html: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /src/app/editor/popups/import/import-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Observable, Subject } from 'rxjs'; 3 | 4 | import { PopupComponent } from '../popup.service'; 5 | import { ImportPopupMode } from './import-popup-mode'; 6 | import { DataImporter } from './importers/data-importer'; 7 | import { TemplateImporter } from './importers/template-importer'; 8 | 9 | @Component({ 10 | templateUrl: './import-popup.component.html' 11 | }) 12 | export class ImportPopupComponent implements PopupComponent { 13 | 14 | public readonly result: Subject = new Subject(); 15 | 16 | @Input() 17 | public mode: ImportPopupMode; 18 | 19 | public file: File; 20 | public processing = false; 21 | 22 | public constructor( 23 | private readonly templateImporter: TemplateImporter, 24 | private readonly dataImporter: DataImporter) { 25 | } 26 | 27 | public onChanged(event: Event) { 28 | const files = (event.target as any).files as File[]; 29 | if (files && files.length > 0) { 30 | this.file = files[0]; 31 | } 32 | } 33 | 34 | public import() { 35 | if (!this.processing && this.file) { 36 | this.processing = true; 37 | 38 | let res: Observable = null; 39 | switch (this.mode) { 40 | case 'template': 41 | res = this.templateImporter.import(this.file); 42 | break; 43 | 44 | case 'data': 45 | res = this.dataImporter.import(this.file); 46 | break; 47 | 48 | default: 49 | throw new Error(`Not supported mode ${this.mode}.`); 50 | } 51 | 52 | res.subscribe(() => { 53 | this.processing = false; 54 | this._close(); 55 | }, (e) => { 56 | console.error(e); 57 | alert(e); 58 | this.processing = false; 59 | }); 60 | } 61 | } 62 | 63 | public close() { 64 | if (!this.processing) { 65 | this._close(); 66 | } 67 | } 68 | 69 | private _close() { 70 | this.result.next(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/app/editor/popups/import/import-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { PopupService } from '../popup.service'; 4 | import { ImportPopupMode } from './import-popup-mode'; 5 | import { ImportPopupComponent } from './import-popup.component'; 6 | 7 | @Injectable() 8 | export class ImportPopupService { 9 | 10 | public constructor( 11 | private readonly popupService: PopupService) { 12 | } 13 | 14 | public show(mode: ImportPopupMode) { 15 | this.popupService.open(ImportPopupComponent, i => { 16 | i.mode = mode; 17 | }).subscribe(() => {}); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/editor/popups/import/importers/data-importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { StateService } from 'src/app/editor/state.service'; 4 | import { TEMPLATE_DATA_FILE_NAME } from 't3mpl-core/core/constants'; 5 | import { DataSerializer } from 't3mpl-core/core/data/data-serializer'; 6 | import { MemoryStorage } from 't3mpl-core/core/memory-storage'; 7 | 8 | import { unzipToStorage } from './zip-utils'; 9 | 10 | @Injectable() 11 | export class DataImporter { 12 | 13 | public constructor( 14 | private readonly stateService: StateService) { 15 | } 16 | 17 | public import(file: File): Observable { 18 | return new Observable(r => { 19 | const contentStorage = new MemoryStorage(); 20 | unzipToStorage(file, contentStorage).then(() => { 21 | try { 22 | const manifest = this.stateService.templateManifest; 23 | const dataSerializer = new DataSerializer(); 24 | 25 | const templateDataRaw = contentStorage.getContent('text', TEMPLATE_DATA_FILE_NAME); 26 | const templateData = dataSerializer.deserialize(templateDataRaw); 27 | 28 | if (templateData.meta.name !== manifest.meta.name) { 29 | throw new Error(`The data file contains a wrong template name: ${templateData.meta.name}. Expected: ${manifest.meta.name}.`); 30 | } 31 | 32 | this.stateService.setState( 33 | this.stateService.templateSource, 34 | this.stateService.templateManifest, 35 | this.stateService.templateStorage, 36 | contentStorage, 37 | templateData); 38 | r.next(); 39 | } catch (e) { 40 | r.error(e); 41 | } 42 | r.complete(); 43 | }, e => r.error(e)); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/editor/popups/import/importers/template-importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { StateService } from 'src/app/editor/state.service'; 4 | import { TEMPLATE_MANIFEST_FILE_NAME } from 't3mpl-core/core/constants'; 5 | import { MemoryStorage } from 't3mpl-core/core/memory-storage'; 6 | import { TemplateManifestParser } from 't3mpl-core/core/template-manifest-parser'; 7 | 8 | import { unzipToStorage } from './zip-utils'; 9 | 10 | @Injectable() 11 | export class TemplateImporter { 12 | 13 | public constructor( 14 | private readonly stateService: StateService) { 15 | } 16 | 17 | public import(file: File): Observable { 18 | return new Observable(r => { 19 | const templateStorage = new MemoryStorage(); 20 | unzipToStorage(file, templateStorage).then(() => { 21 | try { 22 | const manifestRaw = templateStorage.getContent('text', TEMPLATE_MANIFEST_FILE_NAME); 23 | const manifestParser = new TemplateManifestParser(); 24 | const manifest = manifestParser.parse(manifestRaw); 25 | 26 | this.stateService.setState( 27 | { type: 'file' }, 28 | manifest, 29 | templateStorage, 30 | null, 31 | null); 32 | r.next(); 33 | } catch (e) { 34 | r.error(e); 35 | } 36 | r.complete(); 37 | }, e => r.error(e)); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/editor/popups/import/importers/zip-utils.ts: -------------------------------------------------------------------------------- 1 | import * as JSZip from 'jszip'; 2 | import { WritableStorage } from 't3mpl-core/core/storage'; 3 | import { getFileExt, isTextFileExt } from 't3mpl-core/core/utils/path-utils'; 4 | 5 | export async function unzipToStorage(file: File, storage: WritableStorage): Promise { 6 | const zip = new JSZip(); 7 | await zip.loadAsync(file); 8 | 9 | const filePaths = Object.keys(zip.files) 10 | .filter(fp => !fp.endsWith('/')); 11 | 12 | for (const filePath of filePaths) { 13 | const fileExt = getFileExt(filePath); 14 | const isText = isTextFileExt(fileExt); 15 | const zipFile = zip.file(filePath); 16 | 17 | if (isText) { 18 | const textContent = await zipFile.async('text'); 19 | storage.setContent('text', filePath, textContent); 20 | } else { 21 | const dataUrlContent = await convertBlobToDataUrl(zipFile.async('blob')); 22 | storage.setContent('dataUrl', filePath, dataUrlContent); 23 | } 24 | } 25 | } 26 | 27 | function convertBlobToDataUrl(p: Promise): Promise { 28 | return new Promise((resolve, reject) => { 29 | p.then(blob => { 30 | const reader = new FileReader(); 31 | reader.onload = () => { 32 | resolve(reader.result as string); 33 | }; 34 | reader.onerror = e => reject(e); 35 | reader.readAsDataURL(blob); 36 | }, e => reject(e)); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/editor/popups/loader/loader-popup.component.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /src/app/editor/popups/loader/loader-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | import { TEMPLATE_DATA_FILE_NAME, TEMPLATE_MANIFEST_FILE_NAME } from 't3mpl-core/core/constants'; 4 | 5 | import { StateService } from '../../state.service'; 6 | import { TemplateSource } from '../../template-source'; 7 | import { PopupComponent } from '../popup.service'; 8 | import { loadData, loadTemplate, OnFileLoadedHandler } from './remote-template-loader'; 9 | 10 | @Component({ 11 | templateUrl: './loader-popup.component.html' 12 | }) 13 | export class LoaderPopupComponent implements OnInit, PopupComponent { 14 | 15 | @Input() 16 | public templateSource: TemplateSource; 17 | 18 | public readonly result: Subject = new Subject(); 19 | 20 | public processing: boolean; 21 | public log = ''; 22 | 23 | public constructor( 24 | private readonly stateService: StateService) { 25 | } 26 | 27 | public ngOnInit() { 28 | this.load(); 29 | } 30 | 31 | private async load() { 32 | this.processing = true; 33 | try { 34 | const b = await loadBatches(this.templateSource, p => this.onFileLoaded(p)); 35 | if (b.template) { 36 | this.stateService.setState( 37 | this.templateSource, 38 | b.template.templateManifest, 39 | b.template.templateStorage, 40 | b.data?.contentStorage, 41 | b.data?.templateData); 42 | this.close(); 43 | } 44 | } catch (e) { 45 | const message = e instanceof Error ? e.message : e.toString(); 46 | console.error(e); 47 | this.appendLog(`An error occurred. ${message}`); 48 | } finally { 49 | this.processing = false; 50 | } 51 | } 52 | 53 | public close() { 54 | this.result.next(null); 55 | } 56 | 57 | private onFileLoaded(filePath: string) { 58 | this.appendLog(`Loaded ${filePath}`); 59 | } 60 | 61 | private appendLog(message: string) { 62 | if (this.log) { 63 | this.log += '\r\n'; 64 | } 65 | this.log += message; 66 | } 67 | } 68 | 69 | async function loadBatches(templateSource: TemplateSource, onFileLoaded: OnFileLoadedHandler) { 70 | switch (templateSource.type) { 71 | case 'remoteTemplate': 72 | return { 73 | template: await loadTemplate(templateSource.manifestUrl, onFileLoaded), 74 | data: null 75 | }; 76 | 77 | case 'server': 78 | const p = await Promise.all([ 79 | loadTemplate(templateSource.websiteUrl + '/template/' + TEMPLATE_MANIFEST_FILE_NAME, onFileLoaded), 80 | loadData(templateSource.websiteUrl + '/data/' + TEMPLATE_DATA_FILE_NAME, onFileLoaded) 81 | ]); 82 | return { 83 | template: p[0], 84 | data: p[1] 85 | }; 86 | 87 | default: 88 | throw new Error(`Not supported source type: ${templateSource.type}.`); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/editor/popups/loader/loader-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { TemplateSource } from '../../template-source'; 4 | import { PopupService } from '../popup.service'; 5 | import { LoaderPopupComponent } from './loader-popup.component'; 6 | 7 | @Injectable() 8 | export class LoaderPopupService { 9 | 10 | public constructor( 11 | private readonly popupService: PopupService) { 12 | } 13 | 14 | public load(templateSource: TemplateSource) { 15 | this.popupService.open(LoaderPopupComponent, i => { 16 | i.templateSource = templateSource; 17 | }).subscribe(() => {}); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/editor/popups/loader/remote-template-loader.ts: -------------------------------------------------------------------------------- 1 | import { DataSerializer } from 't3mpl-core/core/data/data-serializer'; 2 | import { MemoryStorage } from 't3mpl-core/core/memory-storage'; 3 | import { TemplateData, TemplateManifest } from 't3mpl-core/core/model'; 4 | import { TemplateManifestParser } from 't3mpl-core/core/template-manifest-parser'; 5 | import { getBasePath, getFileExt, isTextFileExt } from 't3mpl-core/core/utils/path-utils'; 6 | 7 | import { ajaxAsBase64, ajaxAsText } from '../../core/ajax'; 8 | 9 | export class TemplateBatch { 10 | templateManifest: TemplateManifest; 11 | templateStorage: MemoryStorage; 12 | } 13 | 14 | export class TemplateDataBatch { 15 | templateData: TemplateData; 16 | contentStorage: MemoryStorage; 17 | } 18 | 19 | export type OnFileLoadedHandler = (filePath: string) => void; 20 | 21 | export async function loadTemplate(manifestUrl: string, onLoaded: OnFileLoadedHandler): Promise { 22 | const manifestRaw = await ajaxAsText('GET', manifestUrl); 23 | const parser = new TemplateManifestParser(); 24 | const templateManifest = parser.parse(manifestRaw); 25 | 26 | const baseUrl = getBasePath(manifestUrl); 27 | const templateStorage = await loadToStorage(baseUrl, templateManifest.meta.filePaths, onLoaded); 28 | templateStorage.setContent('text', 'template.yaml', manifestRaw); 29 | 30 | return { 31 | templateManifest, 32 | templateStorage 33 | }; 34 | } 35 | 36 | export async function loadData(dataUrl: string, onLoaded: OnFileLoadedHandler): Promise { 37 | const dataSerializer = new DataSerializer(); 38 | 39 | const dataRaw = await ajaxAsText('GET', dataUrl); 40 | const templateData = dataSerializer.deserialize(dataRaw); 41 | 42 | const baseUrl = getBasePath(dataUrl); 43 | const contentStorage = await loadToStorage(baseUrl, templateData.meta.filePaths, onLoaded); 44 | contentStorage.setContent('text', 'data.json', dataRaw); 45 | 46 | return { 47 | templateData, 48 | contentStorage 49 | }; 50 | } 51 | 52 | async function loadToStorage(baseUrl: string, filePaths: string[], onLoaded: OnFileLoadedHandler): Promise { 53 | const storage = new MemoryStorage(); 54 | const isText = filePaths.map(filePath => { 55 | const fileExt = getFileExt(filePath); 56 | return isTextFileExt(fileExt); 57 | }); 58 | 59 | const contents = await Promise.all(filePaths.map(async (filePath, i) => { 60 | const contentUrl = baseUrl + filePath; 61 | const res = isText[i] 62 | ? await ajaxAsText('GET', contentUrl) 63 | : await ajaxAsBase64('GET', contentUrl); 64 | onLoaded(filePath); 65 | return res; 66 | })); 67 | 68 | filePaths.forEach((filePath, i) => { 69 | if (isText[i]) { 70 | storage.setContent('text', filePath, contents[i]); 71 | } else { 72 | storage.setContent('dataUrl', filePath, contents[i]); 73 | } 74 | }); 75 | return storage; 76 | } 77 | -------------------------------------------------------------------------------- /src/app/editor/popups/markdown-editor/insert-at-cursor.ts: -------------------------------------------------------------------------------- 1 | 2 | export function insertAtCursor(input: HTMLTextAreaElement, text: string) { 3 | if (window.navigator.userAgent.indexOf('Edge') >= 0) { // Microsoft Edge 4 | const start = input.selectionStart; 5 | const end = input.selectionEnd; 6 | 7 | input.value = input.value.substring(0, start) + text + input.value.substring(end, input.value.length); 8 | 9 | const pos = start + text.length; 10 | input.focus(); 11 | input.setSelectionRange(pos, pos); 12 | } else if (input.selectionStart || input.selectionStart === 0) { 13 | const start = input.selectionStart; 14 | const end = input.selectionEnd; 15 | input.value = input.value.substring(0, start) 16 | + text 17 | + input.value.substring(end, input.value.length); 18 | } else { 19 | input.value += text; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/editor/popups/markdown-editor/markdown-editor-popup.component.html: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /src/app/editor/popups/markdown-editor/markdown-editor-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | import { IMAGE_CONTENT_BASE_PATH, MARKDOWN_CONTENT_BASE_PATH, MARKDOWN_CONTENT_EXT } from 't3mpl-core/core/constants'; 4 | import { generateFileName } from 't3mpl-core/core/utils/file-name-generator'; 5 | import { getFileExt } from 't3mpl-core/core/utils/path-utils'; 6 | 7 | import { FileLoader } from '../../core/file-loader'; 8 | import { StateService } from '../../state.service'; 9 | import { PopupComponent } from '../popup.service'; 10 | import { insertAtCursor } from './insert-at-cursor'; 11 | 12 | @Component({ 13 | templateUrl: './markdown-editor-popup.component.html' 14 | }) 15 | export class MarkdownEditorPopupComponent implements OnInit, PopupComponent { 16 | 17 | public readonly result: Subject = new Subject(); 18 | 19 | @Input() 20 | public filePath: string; 21 | 22 | @ViewChild('textarea', { static: true }) 23 | public textarea: ElementRef; 24 | 25 | public constructor( 26 | private readonly stateService: StateService) { 27 | } 28 | 29 | public ngOnInit() { 30 | if (this.filePath) { 31 | this.textarea.nativeElement.value = this.stateService.contentStorage.getContent('text', this.filePath); 32 | } 33 | } 34 | 35 | public addImage() { 36 | const input = document.createElement('input'); 37 | input.type = 'file'; 38 | input.multiple = false; 39 | input.addEventListener('change', () => { 40 | if (input.files.length > 0) { 41 | this.loadAndAddImage(input.files[0]); 42 | } 43 | }); 44 | input.click(); 45 | } 46 | 47 | private async loadAndAddImage(file: File) { 48 | const content = await FileLoader.loadAsDataURL(file); 49 | 50 | const filePath = IMAGE_CONTENT_BASE_PATH + generateFileName({ 51 | fileExt: getFileExt(file.name) 52 | }); 53 | 54 | this.stateService.contentStorage.setContent('dataUrl', filePath, content); 55 | insertAtCursor(this.textarea.nativeElement, `![${file.name}](${filePath})`); 56 | } 57 | 58 | public save() { 59 | const content = this.textarea.nativeElement.value; 60 | if (content) { 61 | if (!this.filePath) { 62 | this.filePath = MARKDOWN_CONTENT_BASE_PATH + generateFileName({ 63 | fileExt: MARKDOWN_CONTENT_EXT 64 | }); 65 | } 66 | 67 | this.stateService.contentStorage.setContent('text', this.filePath, content); 68 | this.result.next(this.filePath); 69 | } else { 70 | this.result.next(null); 71 | } 72 | } 73 | 74 | public close() { 75 | this.result.next(this.filePath); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/editor/popups/markdown-editor/markdown-editor-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { PopupService } from '../popup.service'; 5 | import { MarkdownEditorPopupComponent } from './markdown-editor-popup.component'; 6 | 7 | @Injectable() 8 | export class MarkdownEditorPopupService { 9 | 10 | public constructor( 11 | private readonly popupService: PopupService) { 12 | } 13 | 14 | public edit(filePath: string): Observable { 15 | return this.popupService.open(MarkdownEditorPopupComponent, i => { 16 | i.filePath = filePath; 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/editor/popups/popup.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { TranslateModule } from '@ngx-translate/core'; 5 | 6 | import { ConfirmPopupComponent } from './confirm/confirm-popup.component'; 7 | import { ConfirmPopupService } from './confirm/confirm-popup.service'; 8 | import { ExportPopupComponent } from './export/export-popup.component'; 9 | import { ExportPopupService } from './export/export-popup.service'; 10 | import { DataZipGenerator } from './export/generators/data-zip-generator'; 11 | import { ReleaseZipGenerator } from './export/generators/relase-zip-generator'; 12 | import { TemplateZipGenerator } from './export/generators/template-zip-generator'; 13 | import { HtmlEditorPopupComponent } from './html-editor/html-editor-popup.component'; 14 | import { HtmlEditorPopupService } from './html-editor/html-editor-popup.service'; 15 | import { ImagePickerPopupComponent } from './image-picker/image-picker-popup.component'; 16 | import { ImagePickerPopupService } from './image-picker/image-picker-popup.service'; 17 | import { ImportPopupComponent } from './import/import-popup.component'; 18 | import { ImportPopupService } from './import/import-popup.service'; 19 | import { DataImporter } from './import/importers/data-importer'; 20 | import { TemplateImporter } from './import/importers/template-importer'; 21 | import { LoaderPopupComponent } from './loader/loader-popup.component'; 22 | import { LoaderPopupService } from './loader/loader-popup.service'; 23 | import { MarkdownEditorPopupComponent } from './markdown-editor/markdown-editor-popup.component'; 24 | import { MarkdownEditorPopupService } from './markdown-editor/markdown-editor-popup.service'; 25 | import { PopupService } from './popup.service'; 26 | import { ScrollDownDirective } from './scroll-down.directive'; 27 | import { TemplateInfoPopupComponent } from './template-info/template-info-popup.component'; 28 | import { TemplateInfoPopupService } from './template-info/template-info-popup.service'; 29 | import { UploaderPopupComponent } from './uploader/uploader-popup.component'; 30 | import { UploaderPopupService } from './uploader/uploader-popup.service'; 31 | 32 | @NgModule({ 33 | declarations: [ 34 | ConfirmPopupComponent, 35 | ImagePickerPopupComponent, 36 | HtmlEditorPopupComponent, 37 | MarkdownEditorPopupComponent, 38 | ExportPopupComponent, 39 | ImportPopupComponent, 40 | TemplateInfoPopupComponent, 41 | LoaderPopupComponent, 42 | UploaderPopupComponent, 43 | ScrollDownDirective 44 | ], 45 | entryComponents: [ 46 | ConfirmPopupComponent, 47 | ImagePickerPopupComponent, 48 | HtmlEditorPopupComponent, 49 | MarkdownEditorPopupComponent, 50 | ExportPopupComponent, 51 | ImportPopupComponent, 52 | TemplateInfoPopupComponent, 53 | LoaderPopupComponent, 54 | UploaderPopupComponent 55 | ], 56 | providers: [ 57 | PopupService, 58 | ConfirmPopupService, 59 | 60 | ImagePickerPopupService, 61 | HtmlEditorPopupService, 62 | MarkdownEditorPopupService, 63 | TemplateInfoPopupService, 64 | 65 | ExportPopupService, 66 | ReleaseZipGenerator, 67 | DataZipGenerator, 68 | TemplateZipGenerator, 69 | 70 | ImportPopupService, 71 | TemplateImporter, 72 | DataImporter, 73 | 74 | LoaderPopupService, 75 | UploaderPopupService 76 | ], 77 | imports: [ 78 | BrowserModule, 79 | CommonModule, 80 | TranslateModule.forChild() 81 | ] 82 | }) 83 | export class PopupModule { 84 | } 85 | -------------------------------------------------------------------------------- /src/app/editor/popups/popup.service.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFactoryResolver, Injectable, Type, ViewContainerRef } from '@angular/core'; 2 | import { Observable, Subject } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | @Injectable() 6 | export class PopupService { 7 | 8 | private container: ViewContainerRef; 9 | 10 | public constructor( 11 | private readonly factoryResolver: ComponentFactoryResolver) { 12 | } 13 | 14 | public setContainer(container: ViewContainerRef) { 15 | this.container = container; 16 | } 17 | 18 | public open>(type: Type, setup: (instance: T) => void): Observable { 19 | if (!this.container) { 20 | throw new Error('The container is required.'); 21 | } 22 | 23 | const factory = this.factoryResolver.resolveComponentFactory(type); 24 | const component = factory.create(this.container.injector); 25 | setup(component.instance); 26 | this.container.insert(component.hostView); 27 | 28 | return component.instance.result 29 | .pipe(map(r => { 30 | this.container.remove(); 31 | return r; 32 | })); 33 | } 34 | } 35 | 36 | export interface PopupComponent { 37 | result: Subject; 38 | } 39 | -------------------------------------------------------------------------------- /src/app/editor/popups/scroll-down.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, Input, OnChanges } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[appScrollDown]' 5 | }) 6 | export class ScrollDownDirective implements OnChanges { 7 | 8 | @Input() 9 | public follow: any; 10 | 11 | public constructor( 12 | private readonly el: ElementRef) { 13 | } 14 | 15 | public ngOnChanges() { 16 | setTimeout(() => { 17 | this.el.nativeElement.scrollTop = this.el.nativeElement.scrollHeight; 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/editor/popups/template-info/template-info-popup.component.html: -------------------------------------------------------------------------------- 1 | 43 | -------------------------------------------------------------------------------- /src/app/editor/popups/template-info/template-info-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | import { TemplateManifestMeta } from 't3mpl-core/core/model'; 4 | 5 | import { StateService } from '../../state.service'; 6 | import { PopupComponent } from '../popup.service'; 7 | 8 | @Component({ 9 | templateUrl: './template-info-popup.component.html' 10 | }) 11 | export class TemplateInfoPopupComponent implements OnInit, PopupComponent { 12 | 13 | public readonly result: Subject = new Subject(); 14 | 15 | public meta: TemplateManifestMeta; 16 | 17 | public constructor( 18 | private readonly stateService: StateService) { 19 | } 20 | 21 | public ngOnInit() { 22 | this.meta = this.stateService.templateManifest.meta; 23 | } 24 | 25 | public close() { 26 | this.result.next(true); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/editor/popups/template-info/template-info-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { PopupService } from '../popup.service'; 4 | import { TemplateInfoPopupComponent } from './template-info-popup.component'; 5 | 6 | @Injectable() 7 | export class TemplateInfoPopupService { 8 | 9 | public constructor( 10 | private readonly popupService: PopupService) { 11 | } 12 | 13 | public open() { 14 | this.popupService.open(TemplateInfoPopupComponent, () => {}) 15 | .subscribe(() => {}); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/editor/popups/uploader/template-data-uploader.ts: -------------------------------------------------------------------------------- 1 | import { DataSerializer } from 't3mpl-core/core/data/data-serializer'; 2 | import { Exporter } from 't3mpl-core/core/exporter'; 3 | import { TemplateData, TemplateManifest } from 't3mpl-core/core/model'; 4 | import { UsedFilesScanner } from 't3mpl-core/core/scanners/used-files-scanner'; 5 | import { ContentType, ReadableStorage } from 't3mpl-core/core/storage'; 6 | 7 | import { ajax } from '../../core/ajax'; 8 | import { convertDataUrlToBlob } from '../../core/dataurl-to-blob-converter'; 9 | 10 | export type LoggerHandler = (message: string) => void; 11 | 12 | export async function uploadTemplateData( 13 | websiteUrl: string, 14 | templateManifest: TemplateManifest, 15 | templateData: TemplateData, 16 | contentStorage: ReadableStorage, 17 | logger: LoggerHandler): Promise { 18 | 19 | logger('Connecting...'); 20 | const artefact = await ajax({ 21 | method: 'POST', 22 | url: websiteUrl, 23 | responseType: 'json' 24 | }); 25 | logger(`Connected ${artefact.commit}`); 26 | 27 | logger('Uploading...'); 28 | const queue: QueueItem[] = []; 29 | Exporter.exportData( 30 | templateManifest, 31 | templateData, 32 | contentStorage, 33 | new DataSerializer(), 34 | new UsedFilesScanner(contentStorage), 35 | (filePath, contentType, content) => { 36 | queue.push({ 37 | filePath, 38 | contentType, 39 | content 40 | }); 41 | } 42 | ); 43 | 44 | const batchSize = 4; 45 | for (let i = 0; i < queue.length; i += batchSize) { 46 | const promises = []; 47 | const messages = []; 48 | for (let j = 0; j < i + batchSize && j < queue.length; j++) { 49 | const item = queue[j]; 50 | const url = websiteUrl + artefact.dataDir + item.filePath; 51 | promises.push(uploadFile(url, item.contentType, item.content)); 52 | messages.push(`Uploaded ${item.filePath}`); 53 | } 54 | await Promise.all(promises); 55 | messages.forEach(m => logger(m)); 56 | } 57 | 58 | logger('Publishing...'); 59 | await ajax({ 60 | method: 'POST', 61 | url: websiteUrl + artefact.commit, 62 | responseType: 'json' 63 | }); 64 | 65 | logger('Success!'); 66 | } 67 | 68 | function uploadFile(url: string, contentType: ContentType, content: string): Promise { 69 | if (contentType === 'text') { 70 | return ajax({ 71 | method: 'PUT', 72 | url, 73 | contentType: 'text/plain', 74 | responseType: 'json', 75 | timeout: 30000, 76 | file: content 77 | }); 78 | } else { 79 | const blob = convertDataUrlToBlob(content); 80 | return ajax({ 81 | method: 'PUT', 82 | url, 83 | contentType: 'application/octet-stream', 84 | responseType: 'json', 85 | timeout: 30000, 86 | file: blob 87 | }); 88 | } 89 | } 90 | 91 | type QueueItem = { 92 | filePath: string, 93 | contentType: ContentType, 94 | content: string 95 | }; 96 | 97 | interface CreateArtefactResponse { 98 | dataDir: string; 99 | commit: string; 100 | } 101 | 102 | interface PutArtefactFileResponse { 103 | success: boolean; 104 | } 105 | -------------------------------------------------------------------------------- /src/app/editor/popups/uploader/uploader-popup.component.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /src/app/editor/popups/uploader/uploader-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | 4 | import { StateService } from '../../state.service'; 5 | import { PopupComponent } from '../popup.service'; 6 | import { uploadTemplateData } from './template-data-uploader'; 7 | 8 | @Component({ 9 | templateUrl: './uploader-popup.component.html' 10 | }) 11 | export class UploaderPopupComponent implements OnInit, PopupComponent { 12 | 13 | public readonly result: Subject = new Subject(); 14 | 15 | public processing: boolean; 16 | public log = ''; 17 | 18 | public constructor( 19 | private readonly stateService: StateService) { 20 | } 21 | 22 | public async ngOnInit() { 23 | this.processing = true; 24 | 25 | try { 26 | await uploadTemplateData( 27 | this.stateService.templateSource.websiteUrl, 28 | this.stateService.templateManifest, 29 | this.stateService.templateData, 30 | this.stateService.contentStorage, 31 | message => this.appendLog(message) 32 | ); 33 | } 34 | catch (e) { 35 | const message = e instanceof Error ? e.message : e.toString(); 36 | console.error(e); 37 | this.appendLog(`An error occurred. ${message}`); 38 | } 39 | finally { 40 | this.processing = false; 41 | } 42 | } 43 | 44 | public close() { 45 | this.result.next(); 46 | } 47 | 48 | private appendLog(message: string) { 49 | if (this.log) { 50 | this.log += '\r\n'; 51 | } 52 | this.log += message; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/editor/popups/uploader/uploader-popup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { PopupService } from '../popup.service'; 4 | import { UploaderPopupComponent } from './uploader-popup.component'; 5 | 6 | @Injectable() 7 | export class UploaderPopupService { 8 | 9 | public constructor( 10 | private readonly popupService: PopupService) { 11 | } 12 | 13 | public open() { 14 | this.popupService.open(UploaderPopupComponent, () => {}) 15 | .subscribe(() => {}); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/editor/preview/data-preview-renderer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { StateService } from '../state.service'; 4 | import { createCodeDocument } from './preview-utils'; 5 | 6 | @Injectable() 7 | export class DataPreviewRenderer { 8 | 9 | public constructor( 10 | private readonly stateService: StateService) { 11 | } 12 | 13 | public render(): string { 14 | const json = JSON.stringify(this.stateService.templateData.data, null, 2); 15 | return createCodeDocument(json); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/editor/preview/page-tabs.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
    4 |
  • 8 | 9 | {{vfp}} 10 |
  • 11 |
12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/app/editor/preview/page-tabs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { merge } from 'rxjs'; 3 | import { debounceTime } from 'rxjs/operators'; 4 | 5 | import { PreviewMode, StateService } from '../state.service'; 6 | 7 | @Component({ 8 | selector: 'app-page-tabs', 9 | templateUrl: './page-tabs.component.html' 10 | }) 11 | export class PageTabsComponent implements OnInit { 12 | 13 | public currentVirtualFilePath: string; 14 | public virtualFilePaths: string[]; 15 | public previewMode: PreviewMode; 16 | 17 | public constructor( 18 | private readonly stateService: StateService) { 19 | } 20 | 21 | public ngOnInit() { 22 | merge( 23 | this.stateService.onStateChanged, 24 | this.stateService.onPageChanged, 25 | this.stateService.onConfigurationChanged, 26 | this.stateService.onPreviewModeChanged 27 | ) 28 | .pipe(debounceTime(50)) 29 | .subscribe(() => this.reload()); 30 | } 31 | 32 | private reload() { 33 | this.virtualFilePaths = this.stateService.pages.map(p => p.virtualFilePath); 34 | this.currentVirtualFilePath = this.stateService.currentPage 35 | ? this.stateService.currentPage.virtualFilePath 36 | : null; 37 | this.previewMode = this.stateService.previewMode; 38 | } 39 | 40 | public openPage(virtualFilePath: string) { 41 | this.stateService.setCurrentPage(virtualFilePath); 42 | } 43 | 44 | public changeMode(mode: PreviewMode) { 45 | this.stateService.setPreviewMode(mode); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/editor/preview/preview-utils.ts: -------------------------------------------------------------------------------- 1 | 2 | export function createCodeDocument(text: string): string { 3 | const body = document.createElement('body'); 4 | body.style.color = '#000'; 5 | body.style.background = '#FFF'; 6 | body.style.fontSize = '16px'; 7 | const pre = document.createElement('pre'); 8 | pre.innerText = text; 9 | body.appendChild(pre); 10 | return body.outerHTML; 11 | } 12 | 13 | export function createErrorDocument(message: string): string { 14 | const code = document.createElement('code'); 15 | code.style.display = 'block'; 16 | code.style.background = 'red'; 17 | code.style.color = 'white'; 18 | code.style.padding = '1rem'; 19 | code.innerText = message; 20 | return code.outerHTML; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/editor/preview/preview.component.html: -------------------------------------------------------------------------------- 1 |
2 | 4 |
-------------------------------------------------------------------------------- /src/app/editor/preview/preview.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; 2 | import { merge } from 'rxjs'; 3 | import { debounceTime } from 'rxjs/operators'; 4 | 5 | import { StateService } from '../state.service'; 6 | import { DataPreviewRenderer } from './data-preview-renderer'; 7 | import { TemplatePreviewRenderer } from './template-preview-renderer'; 8 | 9 | const OPEN_PAGE_MESSAGE = 'openPage:'; 10 | 11 | @Component({ 12 | selector: 'app-preview', 13 | templateUrl: './preview.component.html' 14 | }) 15 | export class PreviewComponent implements OnInit, OnDestroy { 16 | 17 | @ViewChild('iframe', { static: true }) 18 | public iframe: ElementRef; 19 | 20 | public isMobileMode = false; 21 | 22 | private scrollTop: number = null; 23 | 24 | private onMessageReceivedHandler = (m: MessageEvent) => this.onMessageReceived(m); 25 | 26 | public constructor( 27 | private readonly stateService: StateService, 28 | private readonly templatePreviewRenderer: TemplatePreviewRenderer, 29 | private readonly dataPreviewRenderer: DataPreviewRenderer) { 30 | } 31 | 32 | public ngOnInit() { 33 | window.addEventListener('message', this.onMessageReceivedHandler); 34 | 35 | merge( 36 | this.stateService.onStateChanged, 37 | this.stateService.onDataChanged, 38 | this.stateService.onPageChanged, 39 | this.stateService.onConfigurationChanged 40 | ) 41 | .pipe(debounceTime(50)) 42 | .subscribe(() => this.render()); 43 | 44 | this.stateService.onPreviewModeChanged 45 | .subscribe(() => this.render()); 46 | } 47 | 48 | public ngOnDestroy() { 49 | window.removeEventListener('message', this.onMessageReceivedHandler); 50 | } 51 | 52 | private render() { 53 | let content: string; 54 | let isMobileMode: boolean; 55 | switch (this.stateService.previewMode) { 56 | case 'desktop': 57 | case 'mobile': 58 | content = this.templatePreviewRenderer.render(); 59 | isMobileMode = this.stateService.previewMode === 'mobile'; 60 | break; 61 | case 'data': 62 | content = this.dataPreviewRenderer.render(); 63 | isMobileMode = false; 64 | break; 65 | } 66 | 67 | this.isMobileMode = isMobileMode; 68 | this.setContent(content); 69 | 70 | if (this.scrollTop) { 71 | this.iframe.nativeElement.contentWindow.scrollTo(0, this.scrollTop); 72 | } 73 | } 74 | 75 | private setContent(html: string) { 76 | const doc = this.iframe.nativeElement.contentDocument; 77 | doc.open(); 78 | doc.write(html); 79 | doc.addEventListener('scroll', () => this.onPreviewScrolled()); 80 | doc.close(); 81 | } 82 | 83 | private onPreviewScrolled() { 84 | this.scrollTop = this.iframe.nativeElement.contentWindow.scrollY; 85 | } 86 | 87 | private onMessageReceived(e: MessageEvent) { 88 | if (typeof(e.data) === 'string' && e.data.startsWith(OPEN_PAGE_MESSAGE)) { 89 | const pageFileName = e.data.substring(OPEN_PAGE_MESSAGE.length); 90 | this.stateService.setCurrentPage(pageFileName); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app/editor/preview/preview.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { TranslateModule } from '@ngx-translate/core'; 5 | 6 | import { DataPreviewRenderer } from './data-preview-renderer'; 7 | import { PageTabsComponent } from './page-tabs.component'; 8 | import { PreviewComponent } from './preview.component'; 9 | import { TemplatePreviewRenderer } from './template-preview-renderer'; 10 | 11 | @NgModule({ 12 | declarations: [ 13 | PreviewComponent, 14 | PageTabsComponent 15 | ], 16 | exports: [ 17 | PreviewComponent, 18 | PageTabsComponent 19 | ], 20 | providers: [ 21 | TemplatePreviewRenderer, 22 | DataPreviewRenderer 23 | ], 24 | imports: [ 25 | TranslateModule.forChild(), 26 | BrowserModule, 27 | CommonModule 28 | ] 29 | }) 30 | export class PreviewModule { 31 | } 32 | -------------------------------------------------------------------------------- /src/app/editor/preview/template-preview-renderer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { PagesDataGenerator } from 't3mpl-core/core/data/pages-data-generator'; 3 | import { TemplateRenderer } from 't3mpl-core/core/renderer/template-renderer'; 4 | import { getFileExt } from 't3mpl-core/core/utils/path-utils'; 5 | 6 | import { StateService } from '../state.service'; 7 | import { createCodeDocument, createErrorDocument } from './preview-utils'; 8 | 9 | @Injectable() 10 | export class TemplatePreviewRenderer { 11 | 12 | public constructor( 13 | private readonly stateService: StateService) { 14 | } 15 | 16 | public render(): string { 17 | let html: string; 18 | 19 | if (this.stateService.currentPage) { 20 | try { 21 | const start = new Date().getTime(); 22 | 23 | const renderer = new TemplateRenderer( 24 | true, 25 | this.stateService.templateStorage, 26 | this.stateService.contentStorage, 27 | new PagesDataGenerator()); 28 | html = renderer.render( 29 | this.stateService.pages, 30 | this.stateService.currentPage, 31 | this.stateService.templateData); 32 | 33 | const fileExt = getFileExt(this.stateService.currentPage.filePath); 34 | switch (fileExt.toLowerCase()) { 35 | case '.xml': 36 | case '.json': 37 | html = createCodeDocument(html); 38 | break; 39 | 40 | default: 41 | html += 42 | ``; 55 | break; 56 | } 57 | 58 | const duration = new Date().getTime() - start; 59 | // tslint:disable-next-line 60 | console.debug(`rendering time = ${duration} ms.`); 61 | } catch (e) { 62 | console.error(e); 63 | html = createErrorDocument(e && e.message ? e.message : 'Unknow error.'); 64 | } 65 | } else { 66 | html = createErrorDocument('No pages.'); 67 | } 68 | return html; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/configuration/configuration.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
    4 |
  • 5 | {{ 'configuration.routing' | translate }} 6 |
  • 7 |
8 |
9 |
10 | 11 |
12 |
13 | 14 | 15 | {{ 'configuration.pathStrategy' | translate }} * 16 | {{ 'configuration.pathStrategyExplain' | translate }} 17 | 18 |
19 |
20 | 21 |
    22 |
  • 23 | 27 | {{pps.example}} 28 |
  • 29 |
30 |
31 |
32 |
33 | 34 |
35 |
36 | 37 | 38 | {{ 'configuration.baseUrl' | translate }} 39 | {{ 'configuration.baseUrlExplain' | translate }} 40 | 41 |
42 |
43 | 44 |
45 |
46 | 47 |
48 |
49 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/configuration/configuration.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { TranslateService } from '@ngx-translate/core'; 3 | import { PagePathStrategy, TemplateConfiguration } from 't3mpl-core/core/model'; 4 | 5 | import { StateService } from '../../state.service'; 6 | 7 | @Component({ 8 | selector: 'app-configuration', 9 | templateUrl: './configuration.component.html' 10 | }) 11 | export class ConfigurationComponent implements OnInit { 12 | 13 | public configuration: TemplateConfiguration; 14 | 15 | public pagePathStrategies: PagePathStrategyItem[] = [ 16 | { strategy: PagePathStrategy.absolute, labelKey: 'configuration.absolutePathStrategy', example: '/pages/contact.html', isChecked: false }, 17 | { strategy: PagePathStrategy.directory, labelKey: 'configuration.directoryPathStrategy', example: '/pages/contact/', isChecked: false } 18 | ]; 19 | 20 | public constructor( 21 | private readonly stateService: StateService, 22 | private readonly translate: TranslateService) { 23 | } 24 | 25 | public ngOnInit() { 26 | this.configuration = this.stateService.templateData.configuration; 27 | this.reloadPagePathStrategy(); 28 | } 29 | 30 | private reloadPagePathStrategy() { 31 | this.pagePathStrategies.forEach(pps => { 32 | pps.isChecked = this.configuration.pagePathStrategy === pps.strategy; 33 | if (!pps.label) { 34 | pps.label = this.translate.instant(pps.labelKey); 35 | } 36 | }); 37 | } 38 | 39 | public onPagePathStrategyChanged(strategy: PagePathStrategy) { 40 | this.configuration.pagePathStrategy = strategy; 41 | this.stateService.setConfiguration(this.configuration); 42 | this.reloadPagePathStrategy(); 43 | } 44 | 45 | public onBaseUrlChanged(text?: string) { 46 | const url = text.trim(); 47 | this.configuration.baseUrl = url || null; 48 | this.stateService.setConfiguration(this.configuration); 49 | } 50 | } 51 | 52 | interface PagePathStrategyItem { 53 | strategy: PagePathStrategy; 54 | label?: string; 55 | labelKey: string; 56 | example: string; 57 | isChecked: boolean; 58 | } 59 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/dropdown.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { isEqualOrChildOf } from './dropdown.directive'; 2 | 3 | describe('isEqualOrChildOf', () => { 4 | 5 | it('isEqualOrChildOf() returns proper value', () => { 6 | // 7 | // 8 | // 9 | // 10 | // 11 | // 12 | const A = document.createElement('i'); 13 | const B = document.createElement('i'); 14 | A.appendChild(B); 15 | const C = document.createElement('i'); 16 | A.appendChild(C); 17 | const D = document.createElement('i'); 18 | C.appendChild(D); 19 | 20 | expect(isEqualOrChildOf(B, A)).toBeTrue(); 21 | expect(isEqualOrChildOf(C, A)).toBeTrue(); 22 | expect(isEqualOrChildOf(D, A)).toBeTrue(); 23 | expect(isEqualOrChildOf(A, B)).toBeFalse(); 24 | expect(isEqualOrChildOf(A, C)).toBeFalse(); 25 | expect(isEqualOrChildOf(A, D)).toBeFalse(); 26 | 27 | expect(isEqualOrChildOf(D, C)).toBeTrue(); 28 | expect(isEqualOrChildOf(D, B)).toBeFalse(); 29 | 30 | expect(isEqualOrChildOf(A, A)).toBeTrue(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/dropdown.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, HostListener, Input, OnInit, Renderer2 } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[appDropdown]' 5 | }) 6 | export class DropdownDirective implements OnInit { 7 | 8 | private isVisible: boolean; 9 | 10 | @Input() 11 | public target: HTMLElement; 12 | 13 | public constructor( 14 | private readonly el: ElementRef, 15 | private readonly renderer: Renderer2) { 16 | } 17 | 18 | public ngOnInit() { 19 | this.hide(); 20 | } 21 | 22 | @HostListener('window:click', ['$event']) 23 | public onWindowClicked(event: Event) { 24 | if (this.isVisible) { 25 | this.hide(); 26 | } else if (isEqualOrChildOf(event.target as HTMLElement, this.el.nativeElement)) { 27 | this.show(); 28 | } 29 | } 30 | 31 | @HostListener('window:blur') 32 | public onWindowBlurred() { 33 | if (this.isVisible) { 34 | this.hide(); 35 | } 36 | } 37 | 38 | private hide() { 39 | this.renderer.setStyle(this.target, 'display', 'none'); 40 | this.isVisible = false; 41 | } 42 | 43 | private show() { 44 | this.renderer.setStyle(this.target, 'display', 'block'); 45 | this.isVisible = true; 46 | } 47 | } 48 | 49 | export function isEqualOrChildOf(element: HTMLElement, parent: HTMLElement): boolean { 50 | let current = element; 51 | do { 52 | if (current === parent) { 53 | return true; 54 | } 55 | current = current.parentElement; 56 | } while (current); 57 | return false; 58 | } 59 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/boolean-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/boolean-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { TextPropertyContract } from 't3mpl-core/core/model'; 3 | 4 | import { StateService } from '../../state.service'; 5 | 6 | @Component({ 7 | selector: 'app-boolean-property', 8 | templateUrl: './boolean-property.component.html' 9 | }) 10 | export class BooleanPropertyComponent implements OnInit { 11 | 12 | @Input() 13 | public property: TextPropertyContract; 14 | @Input() 15 | public dataPath: string; 16 | 17 | public value: boolean; 18 | public validationError: string; 19 | 20 | public constructor( 21 | private readonly stateService: StateService) { 22 | } 23 | 24 | public ngOnInit() { 25 | this.value = this.stateService.getValue(this.dataPath); 26 | this.validate(); 27 | } 28 | 29 | public onChanged(value: boolean) { 30 | this.value = value; 31 | this.stateService.setValue(this.dataPath, this.value); 32 | this.validate(); 33 | } 34 | 35 | private validate() { 36 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/choice-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 21 |
22 |
23 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/choice-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { ChoicePropertyContract } from 't3mpl-core/core/model'; 3 | 4 | import { StateService } from '../../state.service'; 5 | 6 | @Component({ 7 | selector: 'app-choice-property', 8 | templateUrl: './choice-property.component.html' 9 | }) 10 | export class ChoicePropertyComponent implements OnInit { 11 | 12 | @Input() 13 | public property: ChoicePropertyContract; 14 | @Input() 15 | public dataPath: string; 16 | 17 | public orderedValues: ChoiceValue[]; 18 | public value: string; 19 | public validationError: string; 20 | 21 | public constructor( 22 | private readonly stateService: StateService) { 23 | } 24 | 25 | public ngOnInit() { 26 | this.value = this.stateService.getValue(this.dataPath); 27 | this.validate(); 28 | 29 | this.orderedValues = Object.keys(this.property.values).map(value => { 30 | return { 31 | value, 32 | label: this.property.values[value] 33 | }; 34 | }); 35 | this.orderedValues.sort((a, b) => a.label.localeCompare(b.label)); 36 | } 37 | 38 | public onValueChanged(value: string) { 39 | this.value = value || null; 40 | this.stateService.setValue(this.dataPath, this.value); 41 | this.validate(); 42 | } 43 | 44 | private validate() { 45 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 46 | } 47 | } 48 | 49 | interface ChoiceValue { 50 | value: string; 51 | label: string; 52 | } 53 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/collection-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 11 | 14 | {{items.length}} items 15 | 16 |
17 |
18 | 19 |
20 |
21 |
22 | {{itemName}} {{items.length - ix}} 23 | 24 | 27 | 30 | 33 |
34 | 35 | 38 | 39 |
40 |
41 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/collection-property.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { getCollectionItemName } from './collection-property.component'; 2 | 3 | describe('getCollectionItemName', () => { 4 | 5 | it('getCollectionItemName() returns proper value', () => { 6 | expect(getCollectionItemName('Rows')).toEqual('Row'); 7 | expect(getCollectionItemName('Menu Items')).toEqual('Menu Item'); 8 | expect(getCollectionItemName('Logo')).toEqual('Item'); 9 | expect(getCollectionItemName('Item')).toEqual('Item'); 10 | expect(getCollectionItemName('s')).toEqual('Item'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/collection-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { CollectionPropertyContract } from 't3mpl-core/core/model'; 3 | 4 | import { StateService } from '../../state.service'; 5 | 6 | @Component({ 7 | selector: 'app-collection-property', 8 | templateUrl: './collection-property.component.html' 9 | }) 10 | export class CollectionPropertyComponent implements OnInit { 11 | 12 | @Input() 13 | public property: CollectionPropertyContract; 14 | @Input() 15 | public dataPath: string; 16 | 17 | public items: any[]; 18 | public itemName: string; 19 | public validationError: string; 20 | public canAddItem: boolean; 21 | 22 | public constructor( 23 | private readonly stateService: StateService) { 24 | } 25 | 26 | public ngOnInit() { 27 | this.itemName = getCollectionItemName(this.property._label); 28 | this.reload(); 29 | } 30 | 31 | private reload() { 32 | this.items = this.stateService.getValue(this.dataPath); 33 | this.canAddItem = this.property.max === null || this.property.max > this.items.length; 34 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.items); 35 | } 36 | 37 | public unshiftItem() { 38 | if (this.canAddItem) { 39 | this.stateService.unshiftItem(this.dataPath, this.property.properties); 40 | this.reload(); 41 | } 42 | } 43 | 44 | public deleteItem(index: number) { 45 | this.stateService.removeItem(this.dataPath, index); 46 | this.reload(); 47 | } 48 | 49 | public moveItem(index: number, direction: number) { 50 | const newIndex = index + direction; 51 | if (newIndex >= 0 && newIndex < this.items.length) { 52 | this.stateService.moveItem(this.dataPath, index, newIndex); 53 | this.reload(); 54 | } 55 | } 56 | } 57 | 58 | export function getCollectionItemName(label: string): string { 59 | if (label.length > 1 && label.endsWith('s')) { 60 | return label.substr(0, label.length - 1); 61 | } 62 | return 'Item'; 63 | } 64 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/color-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/color-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { ColorPropertyContract } from 't3mpl-core/core/model'; 3 | 4 | import { StateService } from '../../state.service'; 5 | 6 | @Component({ 7 | selector: 'app-color-property', 8 | templateUrl: './color-property.component.html' 9 | }) 10 | export class ColorPropertyComponent implements OnInit { 11 | 12 | @Input() 13 | public property: ColorPropertyContract; 14 | @Input() 15 | public dataPath: string; 16 | 17 | public value: string; 18 | public validationError: string; 19 | 20 | public constructor( 21 | private readonly stateService: StateService) { 22 | } 23 | 24 | public ngOnInit() { 25 | this.value = this.stateService.getValue(this.dataPath); 26 | this.validate(); 27 | } 28 | 29 | public onChanged(value: string) { 30 | this.value = value || null; 31 | this.stateService.setValue(this.dataPath, this.value); 32 | this.validate(); 33 | } 34 | 35 | private validate() { 36 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/datetime-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 11 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/datetime-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import * as dayjs from 'dayjs'; 3 | import { TextPropertyContract } from 't3mpl-core/core/model'; 4 | 5 | import { StateService } from '../../state.service'; 6 | 7 | @Component({ 8 | selector: 'app-datetime-property', 9 | templateUrl: './datetime-property.component.html' 10 | }) 11 | export class DatePropertyComponent implements OnInit { 12 | 13 | @Input() 14 | public property: TextPropertyContract; 15 | @Input() 16 | public dataPath: string; 17 | 18 | public value: string; 19 | public dateValue: string; 20 | public timeValue: string; 21 | public validationError: string; 22 | 23 | public constructor( 24 | private readonly stateService: StateService) { 25 | } 26 | 27 | public ngOnInit() { 28 | this.value = this.stateService.getValue(this.dataPath); 29 | if (this.value) { 30 | const date = dayjs(this.value); 31 | this.dateValue = date.format('YYYY-MM-DD'); 32 | this.timeValue = date.format('HH:mm:ss'); 33 | } 34 | this.validate(); 35 | } 36 | 37 | private validate() { 38 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 39 | } 40 | 41 | public onDateChanged(value: string) { 42 | this.dateValue = value; 43 | this.reloadValue(); 44 | } 45 | 46 | public onTimeChanged(value: string) { 47 | this.timeValue = value; 48 | this.reloadValue(); 49 | } 50 | 51 | private reloadValue() { 52 | if (this.timeValue && this.dateValue) { 53 | const date = dayjs(this.dateValue + ' ' + this.timeValue, 'YYYY-MM-DD HH:mm:ss', true); 54 | this.value = date.toISOString(); 55 | } else { 56 | this.value = null; 57 | } 58 | this.stateService.setValue(this.dataPath, this.value); 59 | this.validate(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/html-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 13 | {{charCount}} characters 14 |
15 |
16 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/html-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { HtmlPropertyContract } from 't3mpl-core/core/model'; 3 | 4 | import { HtmlEditorPopupService } from '../../popups/html-editor/html-editor-popup.service'; 5 | import { StateService } from '../../state.service'; 6 | 7 | @Component({ 8 | selector: 'app-html-property', 9 | templateUrl: './html-property.component.html' 10 | }) 11 | export class HtmlPropertyComponent implements OnInit { 12 | 13 | @Input() 14 | public property: HtmlPropertyContract; 15 | @Input() 16 | public dataPath: string; 17 | 18 | public value: string; 19 | public charCount: number; 20 | public validationError: string; 21 | 22 | public constructor( 23 | private readonly stateService: StateService, 24 | private readonly htmlEditorPopupService: HtmlEditorPopupService) { 25 | } 26 | 27 | public ngOnInit() { 28 | this.value = this.stateService.getValue(this.dataPath); 29 | this.validate(); 30 | this.reloadCharCount(); 31 | } 32 | 33 | public edit() { 34 | this.htmlEditorPopupService.edit(this.value) 35 | .subscribe((filePath: string) => { 36 | this.value = filePath; 37 | this.validate(); 38 | this.stateService.setValue(this.dataPath, this.value); 39 | this.reloadCharCount(); 40 | }); 41 | } 42 | 43 | private reloadCharCount() { 44 | if (this.value) { 45 | this.charCount = this.stateService.contentStorage.getContent('text', this.value).length; 46 | } else { 47 | this.charCount = null; 48 | } 49 | } 50 | 51 | private validate() { 52 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/image-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 14 | 15 | {{property.width || '∞'}}×{{property.height || '∞'}} 16 | 17 | 18 | 19 | 20 |
21 |
-------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/image-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; 3 | import { ImagePropertyContract } from 't3mpl-core/core/model'; 4 | 5 | import { ImagePickerPopupService } from '../../popups/image-picker/image-picker-popup.service'; 6 | import { StateService } from '../../state.service'; 7 | 8 | const MAX_HEIGHT = 55; 9 | const MAX_WIDTH = 100; 10 | 11 | @Component({ 12 | selector: 'app-image-property', 13 | templateUrl: './image-property.component.html' 14 | }) 15 | export class ImagePropertyComponent implements OnInit { 16 | 17 | @Input() 18 | public property: ImagePropertyContract; 19 | @Input() 20 | public dataPath: string; 21 | 22 | public value: string; 23 | public previewDataUrl: SafeResourceUrl; 24 | public validationError: string; 25 | 26 | public pickerWidth: number; 27 | public pickerHeight: number; 28 | 29 | public constructor( 30 | private readonly stateService: StateService, 31 | private readonly imagePickerService: ImagePickerPopupService, 32 | private readonly domSanitizer: DomSanitizer) { 33 | } 34 | 35 | public ngOnInit() { 36 | this.value = this.stateService.getValue(this.dataPath); 37 | this.validate(); 38 | this.loadPreview(); 39 | 40 | if (this.property.width && this.property.height) { 41 | if (this.property.width > MAX_WIDTH || this.property.height > MAX_HEIGHT) { 42 | let scale = MAX_WIDTH / this.property.width; 43 | this.pickerWidth = scale * this.property.width; 44 | this.pickerHeight = scale * this.property.height; 45 | if (this.pickerHeight > MAX_HEIGHT) { 46 | scale = MAX_HEIGHT / this.pickerHeight; 47 | this.pickerWidth *= scale; 48 | this.pickerHeight *= scale; 49 | } 50 | } else { 51 | this.pickerWidth = this.property.width; 52 | this.pickerHeight = this.property.height; 53 | } 54 | } else { 55 | this.pickerWidth = MAX_WIDTH / 2; 56 | this.pickerHeight = MAX_HEIGHT / 2; 57 | } 58 | } 59 | 60 | private loadPreview() { 61 | if (this.value) { 62 | const content = this.stateService.contentStorage.getContent('dataUrl', this.value); 63 | this.previewDataUrl = this.domSanitizer.bypassSecurityTrustResourceUrl(content); 64 | } else { 65 | this.previewDataUrl = null; 66 | } 67 | } 68 | 69 | public pick() { 70 | this.imagePickerService.pick(this.value).subscribe(filePath => { 71 | if (this.value !== filePath) { 72 | this.stateService.setValue(this.dataPath, filePath); 73 | this.value = filePath; 74 | this.validate(); 75 | this.loadPreview(); 76 | } 77 | }); 78 | } 79 | 80 | public clear() { 81 | this.stateService.setValue(this.dataPath, null); 82 | this.value = null; 83 | this.previewDataUrl = null; 84 | } 85 | 86 | private validate() { 87 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/markdown-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 13 | {{charCount}} characters 14 |
15 |
16 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/markdown-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { HtmlPropertyContract } from 't3mpl-core/core/model'; 3 | 4 | import { MarkdownEditorPopupService } from '../../popups/markdown-editor/markdown-editor-popup.service'; 5 | import { StateService } from '../../state.service'; 6 | 7 | @Component({ 8 | selector: 'app-markdown-property', 9 | templateUrl: './markdown-property.component.html' 10 | }) 11 | export class MarkdownPropertyComponent implements OnInit { 12 | 13 | @Input() 14 | public property: HtmlPropertyContract; 15 | @Input() 16 | public dataPath: string; 17 | 18 | public value: string; 19 | public charCount: number; 20 | public validationError: string; 21 | 22 | public constructor( 23 | private readonly stateService: StateService, 24 | private readonly markdownEditorPopupService: MarkdownEditorPopupService) { 25 | } 26 | 27 | public ngOnInit() { 28 | this.value = this.stateService.getValue(this.dataPath); 29 | this.validate(); 30 | this.reloadCharCount(); 31 | } 32 | 33 | public edit() { 34 | this.markdownEditorPopupService.edit(this.value) 35 | .subscribe((filePath: string) => { 36 | this.value = filePath; 37 | this.validate(); 38 | this.stateService.setValue(this.dataPath, this.value); 39 | this.reloadCharCount(); 40 | }); 41 | } 42 | 43 | private reloadCharCount() { 44 | if (this.value) { 45 | this.charCount = this.stateService.contentStorage.getContent('text', this.value).length; 46 | } else { 47 | this.charCount = null; 48 | } 49 | } 50 | 51 | private validate() { 52 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/properties.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | Not supported property type: {{p.contract.type}} 15 |
16 | 17 |
18 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/properties.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; 2 | import { PropertyContract, PropertyContractMap } from 't3mpl-core/core/model'; 3 | 4 | @Component({ 5 | selector: 'app-properties', 6 | templateUrl: './properties.component.html' 7 | }) 8 | export class PropertiesComponent implements OnInit, OnChanges { 9 | 10 | @Input() 11 | public properties: PropertyContractMap; 12 | @Input() 13 | public dataPath: string; 14 | 15 | public orderedProperties: Property[]; 16 | 17 | public ngOnInit() { 18 | this.sortProperties(); 19 | } 20 | 21 | public ngOnChanges(changes: SimpleChanges) { 22 | const p = changes.properties; 23 | if (p && !p.firstChange) { 24 | this.sortProperties(); 25 | } 26 | } 27 | 28 | private sortProperties() { 29 | this.orderedProperties = Object.keys(this.properties).map(p => { 30 | return { 31 | propertyName: p, 32 | contract: this.properties[p] 33 | }; 34 | }); 35 | } 36 | } 37 | 38 | interface Property { 39 | propertyName: string; 40 | contract: PropertyContract; 41 | } 42 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/text-property.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | {{property._label}} * 6 | ({{property._description}}) 7 | 8 |
9 |
10 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/properties/text-property.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { TextPropertyContract } from 't3mpl-core/core/model'; 3 | 4 | import { StateService } from '../../state.service'; 5 | 6 | @Component({ 7 | selector: 'app-text-property', 8 | templateUrl: './text-property.component.html' 9 | }) 10 | export class TextPropertyComponent implements OnInit { 11 | 12 | @Input() 13 | public property: TextPropertyContract; 14 | @Input() 15 | public dataPath: string; 16 | 17 | public value: string; 18 | public validationError: string; 19 | 20 | public constructor( 21 | private readonly stateService: StateService) { 22 | } 23 | 24 | public ngOnInit() { 25 | this.value = this.stateService.getValue(this.dataPath); 26 | this.validate(); 27 | } 28 | 29 | public onChanged(value: string) { 30 | this.value = value || null; 31 | this.stateService.setValue(this.dataPath, this.value); 32 | this.validate(); 33 | } 34 | 35 | private validate() { 36 | this.validationError = this.stateService.validate(this.property, this.dataPath, this.value); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sections.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
    5 |
  • 9 | {{s.value._label}} 10 |
  • 11 |
12 |
13 |
14 | 17 | 18 |
19 |
20 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sections.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { SectionContract, SectionContractMap } from 't3mpl-core/core/model'; 3 | 4 | @Component({ 5 | selector: 'app-sections', 6 | templateUrl: './sections.component.html' 7 | }) 8 | export class SectionComponent implements OnInit { 9 | 10 | @Input() 11 | public sections: SectionContractMap; 12 | @Input() 13 | public dataPath: string; 14 | 15 | public currentSectionName: string; 16 | public currentSection: SectionContract; 17 | 18 | public ngOnInit() { 19 | const firstSectionName = Object.keys(this.sections)[0]; 20 | this.selectSection(firstSectionName); 21 | } 22 | 23 | public selectSection(name: string) { 24 | this.currentSectionName = name; 25 | this.currentSection = this.sections[name]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sidebar-menu.component.html: -------------------------------------------------------------------------------- 1 | 4 | 7 | 8 | 36 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sidebar-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { TranslateService } from '@ngx-translate/core'; 3 | import { Observable, of } from 'rxjs'; 4 | import { PROJECT_WEBSITE_URL } from 't3mpl-core/core/constants'; 5 | 6 | import { ConfirmPopupService } from '../popups/confirm/confirm-popup.service'; 7 | import { ExportPopupService } from '../popups/export/export-popup.service'; 8 | import { ImportPopupService } from '../popups/import/import-popup.service'; 9 | import { TemplateInfoPopupService } from '../popups/template-info/template-info-popup.service'; 10 | import { UploaderPopupService } from '../popups/uploader/uploader-popup.service'; 11 | import { StateService } from '../state.service'; 12 | 13 | @Component({ 14 | selector: 'app-sidebar-menu', 15 | templateUrl: './sidebar-menu.component.html' 16 | }) 17 | export class SidebarMenuComponent implements OnInit { 18 | 19 | public hasTemplate: boolean; 20 | public canChangeTemplate: boolean; 21 | public canExportTemplate: boolean; 22 | 23 | public constructor( 24 | private readonly stateService: StateService, 25 | private readonly confirmPopupService: ConfirmPopupService, 26 | private readonly exportPopupService: ExportPopupService, 27 | private readonly importPopupService: ImportPopupService, 28 | private readonly templateInfoPopupService: TemplateInfoPopupService, 29 | private readonly uploaderPopupService: UploaderPopupService, 30 | private readonly translateService: TranslateService) { 31 | } 32 | 33 | public ngOnInit() { 34 | this.stateService.onStateChanged.subscribe(() => this.onStateChanged()); 35 | } 36 | 37 | private onStateChanged() { 38 | const sourceType = this.stateService.templateSource.type; 39 | this.hasTemplate = true; 40 | this.canChangeTemplate = (sourceType === 'file' || sourceType === 'remoteTemplate'); 41 | this.canExportTemplate = this.stateService.templateManifest.meta.exportable; 42 | } 43 | 44 | public publish() { 45 | this.confirmValidation() 46 | .subscribe(result => { 47 | if (result) { 48 | if (this.stateService.templateSource.type === 'server') { 49 | this.confirmPopupService.prompt( 50 | this.translateService.instant('sidebar.confirmPublishingTitle'), 51 | this.translateService.instant('sidebar.confirmPublishingDescription')) 52 | .subscribe((ok) => { 53 | if (ok) { 54 | this.uploaderPopupService.open(); 55 | } 56 | }); 57 | } else { 58 | this.exportPopupService.open('publish'); 59 | } 60 | } 61 | }); 62 | } 63 | 64 | public importTemplate() { 65 | this.importPopupService.show('template'); 66 | } 67 | 68 | public exportTemplate() { 69 | this.exportPopupService.open('template'); 70 | } 71 | 72 | public importData() { 73 | this.importPopupService.show('data'); 74 | } 75 | 76 | public exportData() { 77 | this.confirmValidation() 78 | .subscribe(result => { 79 | if (result) { 80 | this.exportPopupService.open('data'); 81 | } 82 | }); 83 | } 84 | 85 | private confirmValidation(): Observable { 86 | const invalidPropertyNames = this.stateService.validateAll(); 87 | 88 | if (invalidPropertyNames.length === 0) { 89 | return of(true); 90 | } else { 91 | return this.confirmPopupService.prompt( 92 | this.translateService.instant('sidebar.confirmValidationErrorsTitle'), 93 | this.translateService.instant('sidebar.confirmValidationErrorsDescription')); 94 | } 95 | } 96 | 97 | public openTemplateInfo() { 98 | this.templateInfoPopupService.open(); 99 | } 100 | 101 | public exploreTemplates() { 102 | window.open(PROJECT_WEBSITE_URL, '_blank'); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sidebar.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 |
9 | 10 |
11 |
12 | 13 |
14 |
15 | {{templateName}} 16 |
17 |
18 | 27 |
28 |
29 | 30 |
31 | 32 | 33 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sidebar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { SectionContractMap } from 't3mpl-core/core/model'; 2 | 3 | import { groupByPanel } from './sidebar.component'; 4 | 5 | describe('groupByPanel()', () => { 6 | 7 | it('groupByPanel() returns proper value', () => { 8 | const map: SectionContractMap = { 9 | ALFA: { 10 | _panel: 'ubuntu', 11 | properties: {} 12 | }, 13 | BETA: { 14 | _panel: 'linux', 15 | properties: {} 16 | }, 17 | GAMMA: { 18 | _panel: null, 19 | properties: {} 20 | }, 21 | DELTA: { 22 | _panel: 'ubuntu', 23 | properties: {} 24 | }, 25 | OMEGA: { 26 | _panel: 'linux', 27 | properties: {} 28 | } 29 | }; 30 | 31 | const panels = groupByPanel(map); 32 | 33 | const panel1SectionNames = Object.keys(panels[0]); 34 | expect(panel1SectionNames).toContain('ALFA'); 35 | expect(panel1SectionNames).toContain('DELTA'); 36 | 37 | const panel2SectionNames = Object.keys(panels[1]); 38 | expect(panel2SectionNames).toContain('BETA'); 39 | expect(panel2SectionNames).toContain('OMEGA'); 40 | 41 | const panel3SectionNames = Object.keys(panels[2]); 42 | expect(panel3SectionNames).toContain('GAMMA'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sidebar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { SectionContractMap } from 't3mpl-core/core/model'; 3 | 4 | import { version } from '../../../../package.json'; 5 | import { StateService } from '../state.service'; 6 | 7 | @Component({ 8 | selector: 'app-sidebar', 9 | templateUrl: './sidebar.component.html' 10 | }) 11 | export class SidebarComponent implements OnInit { 12 | 13 | public readonly appVersion: string = version; 14 | public templateName: string; 15 | 16 | public items: SidebarItem[]; 17 | public currentItem: SidebarItem; 18 | 19 | public constructor( 20 | private readonly stateService: StateService) { 21 | } 22 | 23 | public ngOnInit() { 24 | this.stateService.onStateChanged.subscribe(() => this.onStateChanged()); 25 | } 26 | 27 | private onStateChanged() { 28 | const manifest = this.stateService.templateManifest; 29 | this.templateName = manifest.meta.name; 30 | 31 | const items: SidebarItem[] = Object.keys(manifest.dataContract.zones).map(zoneName => { 32 | const zone = manifest.dataContract.zones[zoneName]; 33 | return { 34 | type: 'zone', 35 | label: zone._label, 36 | zonePath: zoneName, 37 | zoneSections: groupByPanel(zone.sections) 38 | }; 39 | }); 40 | items.push({ 41 | type: 'separator', 42 | label: '────' 43 | }); 44 | items.push({ 45 | type: 'configuration', 46 | label: 'Configuration' 47 | }); 48 | this.items = items; 49 | this.currentItem = items[0]; 50 | } 51 | 52 | public onItemChanged(itemIndex: number) { 53 | this.currentItem = this.items[itemIndex]; 54 | } 55 | } 56 | 57 | interface SidebarItem { 58 | type: SidebarItemType; 59 | label: string; 60 | zonePath?: string; 61 | zoneSections?: SectionContractMap[]; 62 | } 63 | 64 | type SidebarItemType = 'configuration' | 'separator' | 'zone'; 65 | 66 | export function groupByPanel(map: SectionContractMap): SectionContractMap[] { 67 | const paneled: { [panelName: string]: SectionContractMap } = {}; 68 | const sections: SectionContractMap[] = []; 69 | 70 | for (const sectionName of Object.keys(map)) { 71 | const section = map[sectionName]; 72 | if (section._panel) { 73 | if (!paneled[section._panel]) { 74 | paneled[section._panel] = {}; 75 | sections.push(paneled[section._panel]); 76 | } 77 | paneled[section._panel][sectionName] = section; 78 | } else { 79 | const single = {}; 80 | single[sectionName] = section; 81 | sections.push(single); 82 | } 83 | } 84 | return sections; 85 | } 86 | -------------------------------------------------------------------------------- /src/app/editor/sidebar/sidebar.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { TranslateModule } from '@ngx-translate/core'; 5 | 6 | import { ConfigurationComponent } from './configuration/configuration.component'; 7 | import { DropdownDirective } from './dropdown.directive'; 8 | import { BooleanPropertyComponent } from './properties/boolean-property.component'; 9 | import { ChoicePropertyComponent } from './properties/choice-property.component'; 10 | import { CollectionPropertyComponent } from './properties/collection-property.component'; 11 | import { ColorPropertyComponent } from './properties/color-property.component'; 12 | import { DatePropertyComponent } from './properties/datetime-property.component'; 13 | import { HtmlPropertyComponent } from './properties/html-property.component'; 14 | import { ImagePropertyComponent } from './properties/image-property.component'; 15 | import { MarkdownPropertyComponent } from './properties/markdown-property.component'; 16 | import { PropertiesComponent } from './properties/properties.component'; 17 | import { TextPropertyComponent } from './properties/text-property.component'; 18 | import { SectionComponent } from './sections.component'; 19 | import { SidebarMenuComponent } from './sidebar-menu.component'; 20 | import { SidebarComponent } from './sidebar.component'; 21 | 22 | @NgModule({ 23 | declarations: [ 24 | SidebarComponent, 25 | SidebarMenuComponent, 26 | DropdownDirective, 27 | 28 | ConfigurationComponent, 29 | SectionComponent, 30 | PropertiesComponent, 31 | TextPropertyComponent, 32 | BooleanPropertyComponent, 33 | DatePropertyComponent, 34 | ChoicePropertyComponent, 35 | CollectionPropertyComponent, 36 | HtmlPropertyComponent, 37 | MarkdownPropertyComponent, 38 | ImagePropertyComponent, 39 | ColorPropertyComponent, 40 | ], 41 | exports: [ 42 | SidebarComponent 43 | ], 44 | imports: [ 45 | TranslateModule.forChild(), 46 | BrowserModule, 47 | CommonModule 48 | ] 49 | }) 50 | export class SidebarModule { 51 | } 52 | -------------------------------------------------------------------------------- /src/app/editor/state.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 't3mpl-core/core/model'; 2 | 3 | import { getNextCurrentPage } from './state.service'; 4 | 5 | describe('StateService', () => { 6 | 7 | it('getNextCurrentPage() returns proper value', () => { 8 | const pageO = { filePath: 'o.html', virtualFilePath: 'o/', name: 'O', templateFilePath: 'o.html' }; 9 | const pages1: Page[] = [ 10 | { filePath: 'a.html', virtualFilePath: 'a/', name: 'A', templateFilePath: 'a.html' }, 11 | { filePath: 'b.html', virtualFilePath: 'b/', name: 'B', templateFilePath: 'b.html' }, 12 | { filePath: 'c.html', virtualFilePath: 'c/', name: 'C', templateFilePath: 'c.html' } 13 | ]; 14 | 15 | const pages2 = [...pages1]; 16 | pages2.splice(1, 2); 17 | pages2.push(pageO); 18 | 19 | const pages3 = [...pages1]; 20 | pages3.push(pageO); 21 | 22 | const r1 = getNextCurrentPage(pages1, pages1, pages1[1]); 23 | expect(r1).toBeNull(); 24 | 25 | const r2 = getNextCurrentPage(pages1, pages2, pages1[0]); 26 | expect(r2.virtualFilePath).toEqual('a/'); 27 | 28 | const r3 = getNextCurrentPage([], pages1, null); 29 | expect(r3.virtualFilePath).toEqual('a/'); 30 | 31 | const r4 = getNextCurrentPage(pages1, pages2, pages1[1]); 32 | expect(r4.virtualFilePath).toEqual('a/'); 33 | 34 | const r5 = getNextCurrentPage(pages1, pages3, pages1[0]); 35 | expect(r5.virtualFilePath).toEqual('a/'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/editor/state.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | import { DataActivator } from 't3mpl-core/core/data/data-activator'; 4 | import { DataPath } from 't3mpl-core/core/data/data-path'; 5 | import { DataValidator } from 't3mpl-core/core/data/data-validator'; 6 | import { MemoryStorage } from 't3mpl-core/core/memory-storage'; 7 | import { 8 | Page, 9 | PropertyContract, 10 | PropertyContractMap, 11 | TemplateConfiguration, 12 | TemplateData, 13 | TemplateManifest 14 | } from 't3mpl-core/core/model'; 15 | import { PagesResolver } from 't3mpl-core/core/pages-resolver'; 16 | import { ReadableStorage, WritableStorage } from 't3mpl-core/core/storage'; 17 | import { getDefaultConfiguration } from 't3mpl-core/core/template-configuration'; 18 | 19 | import { TemplateSource } from './template-source'; 20 | 21 | @Injectable() 22 | export class StateService { 23 | 24 | private dataActivator: DataActivator; 25 | private dataValidator: DataValidator; 26 | 27 | public onStateChanged: Subject = new Subject(); 28 | public onDataChanged: Subject = new Subject(); 29 | public onConfigurationChanged: Subject = new Subject(); 30 | public onPageChanged: Subject = new Subject(); 31 | public onPreviewModeChanged: Subject = new Subject(); 32 | 33 | public pages: Page[]; 34 | public currentPage: Page; 35 | 36 | public previewMode: PreviewMode = 'desktop'; 37 | 38 | public templateSource: TemplateSource; 39 | public templateManifest: TemplateManifest; 40 | public templateStorage: ReadableStorage; 41 | public contentStorage: WritableStorage; 42 | public templateData: TemplateData; 43 | 44 | // 45 | 46 | public setState( 47 | templateSource: TemplateSource, 48 | templateManifest: TemplateManifest, 49 | templateStorage: ReadableStorage, 50 | contentStorage?: WritableStorage, 51 | templateData?: TemplateData) { 52 | 53 | if (!contentStorage) { 54 | contentStorage = new MemoryStorage(); 55 | } 56 | this.dataValidator = new DataValidator(); 57 | this.dataActivator = new DataActivator(templateStorage, contentStorage); 58 | if (!templateData) { 59 | templateData = { 60 | meta: { 61 | name: templateManifest.meta.name, 62 | version: templateManifest.meta.version, 63 | filePaths: [] 64 | }, 65 | configuration: getDefaultConfiguration(), 66 | data: this.dataActivator.createInstance(templateManifest.dataContract) 67 | }; 68 | } 69 | 70 | this.templateSource = templateSource; 71 | this.templateManifest = templateManifest; 72 | this.templateStorage = templateStorage; 73 | this.contentStorage = contentStorage; 74 | this.templateData = templateData; 75 | 76 | this.currentPage = null; 77 | this.reloadPages(false); 78 | 79 | this.onStateChanged.next(); 80 | } 81 | 82 | public setPreviewMode(mode: PreviewMode) { 83 | this.previewMode = mode; 84 | this.onPreviewModeChanged.next(); 85 | } 86 | 87 | public setCurrentPage(pageVirtualFilePath: string) { 88 | this.currentPage = this.pages.find(p => p.virtualFilePath === pageVirtualFilePath); 89 | this.onPageChanged.next(); 90 | } 91 | 92 | public setConfiguration(configuration: TemplateConfiguration) { 93 | this.templateData.configuration = configuration; 94 | this.reloadPages(true); 95 | this.onConfigurationChanged.next(); 96 | } 97 | 98 | public validate(propertyContract: PropertyContract, dataPath: string, value: T): string { 99 | const errors = this.dataValidator.validateProperty(propertyContract, dataPath, value); 100 | return errors[dataPath] ? errors[dataPath] : null; 101 | } 102 | 103 | public validateAll(): string[] { 104 | const errors = this.dataValidator.validate(this.templateManifest.dataContract, this.templateData.data); 105 | return Object.keys(errors); 106 | } 107 | 108 | public getValue(dataPath: string): T { 109 | return DataPath.parse(dataPath).get(this.templateData.data); 110 | } 111 | 112 | public setValue(dataPath: string, value: T) { 113 | DataPath.parse(dataPath).set(this.templateData.data, value); 114 | this.dataChanged(dataPath); 115 | } 116 | 117 | public unshiftItem(dataPath: string, map: PropertyContractMap) { 118 | const newItem = this.dataActivator.createPropertiesInstance(map); 119 | DataPath.parse(dataPath).unshiftItem(this.templateData.data, newItem); 120 | this.dataChanged(dataPath); 121 | } 122 | 123 | public removeItem(dataPath: string, index: number) { 124 | DataPath.parse(dataPath).removeItem(this.templateData.data, index); 125 | this.dataChanged(dataPath); 126 | } 127 | 128 | public moveItem(dataPath: string, oldIndex: number, newIndex: number) { 129 | DataPath.parse(dataPath).moveItem(this.templateData.data, oldIndex, newIndex); 130 | this.dataChanged(dataPath); 131 | } 132 | 133 | private dataChanged(dataPath: string) { 134 | this.reloadPages(true); 135 | this.onDataChanged.next(dataPath); 136 | } 137 | 138 | private reloadPages(fireEvent: boolean) { 139 | const prevPages = this.pages; 140 | const pagesResolver = new PagesResolver(this.templateData.configuration.pagePathStrategy); 141 | this.pages = pagesResolver.resolve(this.templateManifest.pages, this.templateData.data); 142 | const newCurrentPage = getNextCurrentPage(prevPages, this.pages, this.currentPage); 143 | if (newCurrentPage) { 144 | this.currentPage = newCurrentPage; 145 | if (fireEvent) { 146 | this.onPageChanged.next(); 147 | } 148 | } 149 | } 150 | } 151 | 152 | export function getNextCurrentPage(prevPages: Page[], newPages: Page[], currentPage: Page) { 153 | if (!currentPage || !newPages.find(p => p.virtualFilePath === currentPage.virtualFilePath)) { 154 | return newPages.length > 0 ? newPages[0] : null; 155 | } 156 | if (prevPages.length !== newPages.length || newPages.find((p, ix) => p.virtualFilePath !== prevPages[ix].virtualFilePath)) { 157 | return currentPage; 158 | } 159 | return null; 160 | } 161 | 162 | export type PreviewMode = 'desktop' | 'mobile' | 'data'; 163 | -------------------------------------------------------------------------------- /src/app/editor/template-source.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface TemplateSource { 3 | type: TemplateSourceType; 4 | manifestUrl?: string; 5 | websiteUrl?: string; 6 | } 7 | 8 | export type TemplateSourceType = 'file' | 'remoteTemplate' | 'server'; 9 | -------------------------------------------------------------------------------- /src/assets/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "sidebar": { 3 | "publish": "Publish", 4 | "importTemplate": "Import template .t3mpl", 5 | "exportTemplate": "Export template .t3mpl", 6 | "importData": "Import data .t3data", 7 | "exportData": "Export data .t3data", 8 | "aboutTemplate": "About template", 9 | "exploreTemplates": "Explore templates", 10 | "confirmPublishingTitle": "Are you sure?", 11 | "confirmPublishingDescription": "This step will update your public website.", 12 | "confirmValidationErrorsTitle": "Validation errors", 13 | "confirmValidationErrorsDescription": "There are validation errors. Do you want to continue?" 14 | }, 15 | "properties": { 16 | "insertItem": "Insert new item", 17 | "moveUpItem": "Move up item", 18 | "moveDownItem": "Move down item", 19 | "deleteItem": "Delete item", 20 | "editHtml": "Edit HTML", 21 | "editMarkdown": "Edit markdown" 22 | }, 23 | "configuration": { 24 | "routing": "Routing", 25 | "pathStrategy": "Path Strategy", 26 | "pathStrategyExplain": "How generate page files", 27 | "absolutePathStrategy": "Absolute Path Strategy", 28 | "directoryPathStrategy": "Directory Path Strategy", 29 | "baseUrl": "Base Website URL", 30 | "baseUrlExplain": "i.e. https://example.com/" 31 | }, 32 | "pageTabs": { 33 | "desktopMode": "Desktop mode", 34 | "mobileMode": "Mobile mode", 35 | "dataMode": "Data mode" 36 | }, 37 | "exportPopup": { 38 | "publishWebsite": "Publish Website", 39 | "exportData": "Export Data", 40 | "exportTemplate": "Export Template", 41 | "considerDonation": "Please consider making a donation.", 42 | "authorOfEditor": "Author of Editor", 43 | "authorOfTemplate": "Author of Template", 44 | "author": "Author", 45 | "donation": "Donation", 46 | "donate": "Donate", 47 | "saveWebsiteAsZip": "Save Website as .zip", 48 | "saveDataAsT3data": "Save Data as .t3data", 49 | "saveTemplateAsT3data": "Save Template as .t3mpl" 50 | }, 51 | "htmlEditorPopup": { 52 | "editHtml": "Edit HTML", 53 | "ok": "Ok", 54 | "cancel": "Cancel" 55 | }, 56 | "imagePickerPopup": { 57 | "pickImage": "Pick Image", 58 | "ok": "Ok", 59 | "cancel": "Cancel" 60 | }, 61 | "importPopup": { 62 | "importData": "Import Data", 63 | "importTemplate": "Import Template", 64 | "import": "Import", 65 | "cancel": "Cancel" 66 | }, 67 | "loaderPopup": { 68 | "title": "Please Wait", 69 | "close": "Close" 70 | }, 71 | "markdownEditorPopup": { 72 | "editMarkdown": "Edit Markdown", 73 | "addImage": "Add image", 74 | "ok": "Ok", 75 | "cancel": "Cancel" 76 | }, 77 | "templateInfoPopup": { 78 | "title": "About Template", 79 | "version": "Version", 80 | "license": "License", 81 | "author": "Author", 82 | "homepage": "Homepage", 83 | "donation": "Donation", 84 | "close": "Close" 85 | }, 86 | "uploaderPopup": { 87 | "title": "Publish Website", 88 | "close": "Close" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/assets/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4rtaz/t3mpl-editor/9e567f1fdd858dfca13503392c491865dee44252/src/assets/og-image.png -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4rtaz/t3mpl-editor/9e567f1fdd858dfca13503392c491865dee44252/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | T3MPL Editor 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/dist/zone'; 2 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | 2 | html, body, h1, h2, h3, h4, h5, h6, p, ul, ol, input, select, button, iframe, textarea, dl, dt, dd {margin: 0; padding: 0; border: 0; outline: 0;} 3 | ul {list-style-type: none;} 4 | a {text-decoration: none;} 5 | 6 | body, input, select, option, button {font: 12px/1.3rem 'Open Sans', 'Helvetica Neue', Arial, Tahoma, Verdana, Serif;} 7 | textarea {font: 13px/1.3em monospace;} 8 | 9 | @mixin background-hover($color) { 10 | & {background: $color; transition: background-color 0.15s linear;} 11 | &:hover {background: lighten($color, 10%);} 12 | } 13 | 14 | $mobile: 'screen and (max-width: 900px)'; 15 | $desktop: 'screen and (min-width: 901px)'; 16 | $margin: 0.7rem; 17 | 18 | $primary: #161616; 19 | $secondary: #606060; 20 | $dark: #303030; 21 | $light: #3B3B3B; 22 | 23 | @media #{$desktop} { 24 | html, body {overflow: hidden;} 25 | } 26 | app-root, app-editor, app-preview {display: block;} 27 | @media #{$mobile} { 28 | .desktop-only {display: none !important;} 29 | } 30 | 31 | .editor { 32 | @media #{$desktop} { 33 | & {position: absolute; z-index: 1; left: 0; top: 0; width: 100vw; height: 100vh; display: flex; flex-direction: row;} 34 | } 35 | 36 | button {color: #FFF; padding: 0.4rem 0.8rem; @include background-hover($secondary); cursor: pointer;} 37 | button:disabled {color: #D1D1D1; background: darken($secondary, 10%); cursor: not-allowed;} 38 | i.mr {margin-right: 0.25rem;} 39 | i.ml {margin-left: 0.25rem;} 40 | 41 | .sidebar {color: #FFF; background: $primary;} 42 | @media #{$desktop} { 43 | .sidebar {width: 28%; min-width: 350px; max-width: 400px;} 44 | .sidebar .container {height: 100%; display: flex; flex-direction: column;} 45 | } 46 | 47 | .sidebar .header { 48 | & {display: table; width: 100%; padding: $margin 0;} 49 | .logo {display: table-cell; vertical-align: middle;} 50 | .logo h1 {font-size: 25px; line-height: 1rem; margin: 0 $margin;} 51 | .logo sup {font-size: 12px; line-height: 1rem; font-weight: normal;} 52 | .control {position: relative; z-index: 20; display: table-cell; text-align: right;} 53 | .control button {padding: 0.4rem 1rem; margin-right: $margin;} 54 | .control button.publish {color: #000; @include background-hover(#EAEAEA);} 55 | .control .menu {position: absolute; right: $margin; text-align: left; box-shadow: 0 3px 3px rgba(0, 0, 0, 0.6);} 56 | .control .menu ul li {padding: $margin $margin * 2; white-space: nowrap; @include background-hover($secondary); cursor: pointer;} 57 | } 58 | 59 | .sidebar .zones { 60 | & {display: table; width: 100%; padding: $margin 0; background: $dark;} 61 | .label {display: table-cell; width: 45%; padding: 0 $margin;} 62 | .label i.fas {margin-right: 0.3rem;} 63 | .value {display: table-cell; padding: 0 $margin;} 64 | .value select {width: 100%; padding: 0.4rem 0.6rem; box-sizing: border-box; color: #FFF; background: $primary;} 65 | } 66 | 67 | .sidebar .sections { 68 | & {overflow: auto;} 69 | @media #{$desktop} { 70 | & {flex: 1;} 71 | } 72 | 73 | .section { 74 | & {margin: $margin * 1.3 0;} 75 | 76 | .tabs ul {margin: 0 $margin;} 77 | .tabs ul li {display: inline-block; padding: 0.4rem 1rem; cursor: pointer; font-weight: bold;} 78 | .tabs ul li.selected {@include background-hover($dark);} 79 | 80 | .body {background: $dark; padding: $margin / 2 0; overflow: hidden;} 81 | .body .property {display: table; width: 100%; margin: $margin / 2 0;} 82 | .body .property .label {display: table-cell; width: 45%; padding: 0 $margin; vertical-align: middle;} 83 | .body .property .label i {vertical-align: middle;} 84 | .body .property .label span.name {display: inline-block; vertical-align: middle;} 85 | .body .property .label span.name span.description {display: block; margin-top: -0.2rem; font-size: 10px; line-height: 1.25rem; color: #C5C5C5;} 86 | .body .property.invalid .label {color: #FFFF00;} 87 | .body .property .label i.fas {margin-right: 0.3rem;} 88 | .body .property .value { 89 | & {display: table-cell; padding: 0 $margin; vertical-align: middle;} 90 | 91 | input[type=text], input[type=date], input[type=time] {width: 100%; color: #FFF; background: $primary; box-sizing: border-box; padding: 0.4rem 0.6rem;} 92 | input[type=color] {width: 100%;} 93 | input[type=checkbox] {margin: 0.2rem 0; cursor: pointer;} 94 | select {width: 100%; padding: 0.4rem 0.6rem; box-sizing: border-box; color: #FFF; background: $primary;} 95 | 96 | span.image-picker {position: relative; display: inline-block; vertical-align: middle; cursor: pointer; @include background-hover(#464646);} 97 | span.image-picker span.placeholder {display: flex; position: relative; z-index: 2; width: 100%; height: 100%; justify-content: center; align-items: center;} 98 | span.image-picker span.placeholder span.size {padding: 0.05rem 0.2rem; font-size: 10px; color: #FFF; background: #1A1A1A; opacity: 0.7;} 99 | span.image-picker img {position: absolute; left: 0; top: 0; z-index: 1;} 100 | button.image-picker-clear {padding: 0.15rem 0.5rem; margin: 0 .5rem; vertical-align: middle;} 101 | 102 | span.info-after-button {margin-left: 0.5rem;} 103 | 104 | span.radio-list ul li {margin: 3px 0;} 105 | span.radio-list ul li label {cursor: pointer;} 106 | span.radio-list ul li label input[type=radio] {vertical-align: middle;} 107 | span.radio-list ul li span.description {display: block; color: #C5C5C5; font-size: 10px; line-height: 1.25rem;} 108 | } 109 | .body .children { 110 | & {margin-left: $margin; border-left: 2px solid darken($light, 10%);} 111 | .child {padding: 0.5rem 0; overflow: hidden;} 112 | .child.even {background: $light;} 113 | .child.odd {background: $dark;} 114 | .child.even .children .child {background: $dark;} 115 | .child.odd .children .child {background: $light;} 116 | .child .control {display: flex; align-items: center; margin: 0 $margin; flex-direction: row;} 117 | .child .control span.occurcene {flex: 1; color: #C5C5C5;} 118 | .child .control button {margin-left: 0.2rem;} 119 | } 120 | } 121 | } 122 | 123 | .main-body { 124 | @media #{$desktop} { 125 | & {display: flex; flex-direction: column; flex: 1;} 126 | } 127 | 128 | .page-tabs {display: flex; flex-direction: row; align-items: center; width: 100%; background: $primary; color: #FFF;} 129 | .page-tabs .tabs {flex: 1;} 130 | .page-tabs .tabs ul li {display: inline-block; padding: 0.4rem 1rem; font-weight: bold; cursor: pointer;} 131 | .page-tabs .tabs ul li.selected {@include background-hover($dark);} 132 | .page-tabs .modes {padding: 0 0.5rem;} 133 | .page-tabs .modes .mode {display: inline-block; margin: 0 0.1rem; padding: 0.1rem; text-align: center; cursor: pointer;} 134 | .page-tabs .modes .mode.selected {background: #FFF; color: #1A1A1A;} 135 | 136 | .preview, .preview iframe {width: 100%; height: 100%;} 137 | .preview {background: #B2B2B2;} 138 | .preview iframe {background: #FFF;} 139 | @media #{$desktop} { 140 | app-preview {flex: 1;} 141 | .preview iframe.mobile {width: 375px; height: 667px; margin: 0 auto;} 142 | } 143 | @media #{$mobile} { 144 | app-preview {height: 90vh; overflow: hidden;} 145 | } 146 | } 147 | 148 | .popup { 149 | & {position: fixed; display: flex; z-index: 9999; left: 0; top: 0; right: 0; bottom: 0; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.85);} 150 | .window {color: #FFF; background: $primary; box-shadow: 0 0 6px rgba(0, 0, 0, 0.6);} 151 | @media #{$desktop} { 152 | .window {width: 35%;} 153 | &.wide .window {width: 80%;} 154 | } 155 | @media #{$mobile} { 156 | .window {width: 100%;} 157 | } 158 | 159 | .header {position: relative; padding: $margin;} 160 | .header button.close {position: absolute; right: 0; top: 0;} 161 | .body {padding: $margin; } 162 | .footer {padding: $margin; background: $dark; text-align: center;} 163 | .footer button {margin: 0 $margin / 2;} 164 | 165 | .body .image-picker .preview {margin-bottom: $margin; text-align: center;} 166 | .body .image-picker input[type=file] {width: 100%;} 167 | 168 | .body .simple-editor-toolbox {padding: 3px; background: #4D4D4D;} 169 | .body .simple-editor textarea {padding: 1rem; width: 100%; height: 300px; box-sizing: border-box; line-height: 1.25rem; resize: vertical; color: #FFF; background: $dark;} 170 | 171 | .body .import input[type=file] {width: 100%;} 172 | 173 | .body textarea.log {width: 100%; height: 150px; color: #FFF; background: transparent; resize: none;} 174 | 175 | .body .text p {padding: $margin * 0.5 0;} 176 | .body .text h4 {padding: $margin 0; font-size: 15px; line-height: 1rem;} 177 | .body .text a {color: #FFF;} 178 | .body .text a:hover {text-decoration: underline;} 179 | .body .text a.big {display: inline-block; padding: 0.4rem 0.8rem; color: #FFF; text-decoration: none !important; @include background-hover($dark);} 180 | .body .text div.dl {display: table; width: 100%; margin: $margin * 0.5 0;} 181 | .body .text div.dl div.label {display: table-cell; width: 20%; vertical-align: middle;} 182 | .body .text div.dl div.value {display: table-cell; vertical-align: middle;} 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/templates/boilerplate/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4rtaz/t3mpl-editor/9e567f1fdd858dfca13503392c491865dee44252/src/templates/boilerplate/assets/favicon.png -------------------------------------------------------------------------------- /src/templates/boilerplate/assets/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4rtaz/t3mpl-editor/9e567f1fdd858dfca13503392c491865dee44252/src/templates/boilerplate/assets/og-image.png -------------------------------------------------------------------------------- /src/templates/boilerplate/assets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 18px/1rem 'Open Sans', Arial; 3 | color: #313131; 4 | background: #CECECE; 5 | } 6 | a { 7 | color: #313131; 8 | text-decoration: underline; 9 | } 10 | a:hover { 11 | text-decoration: none; 12 | } 13 | hr { 14 | border: none; 15 | border-top: 1px solid #7E7E7E; 16 | } -------------------------------------------------------------------------------- /src/templates/boilerplate/footer.partial: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |

{{{$copyright}}} {{COMMON.FOOTER.COPYRIGHT}}

5 | 6 | {{#if COMMON.FOOTER.POWERED_BY}} 7 |

{{{$powered_by}}}

8 | {{/if}} 9 | 10 | {{#if COMMON.FOOTER.POST_HTML}} 11 | {{{$html COMMON.FOOTER.POST_HTML}}} 12 | {{/if}} 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/templates/boilerplate/header.partial: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{#if _title}}{{_title}} - {{/if}}{{COMMON.META.TITLE}} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{{$css 'assets/style.css'}}} 16 | 17 | {{#if COMMON.META.PRE_HTML}} 18 | {{{$html COMMON.META.PRE_HTML}}} 19 | {{/if}} 20 | 21 | 22 | 23 |

24 | 25 | {{COMMON.META.TITLE}} 26 |

27 |

{{COMMON.META.DESCRIPTION}}

28 | 29 | 35 | 36 |
37 | -------------------------------------------------------------------------------- /src/templates/boilerplate/index.html: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 3 | < / > 4 | 5 | {{> footer}} -------------------------------------------------------------------------------- /src/templates/boilerplate/license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Bartlomiej Tadych 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /src/templates/boilerplate/page.html: -------------------------------------------------------------------------------- 1 | {{> header _title=$PAGE.DATA.TITLE}} 2 | 3 |

{{$PAGE.DATA.TITLE}}

4 | 5 | {{#if $PAGE.DATA.CONTENT}} 6 | {{{$markdown $PAGE.DATA.CONTENT}}} 7 | {{/if}} 8 | 9 | {{> footer}} -------------------------------------------------------------------------------- /src/templates/boilerplate/template.yaml: -------------------------------------------------------------------------------- 1 | meta: 2 | version: 1 3 | name: Boilerplate 4 | license: MIT 5 | author: Bartlomiej Tadych (http://n4no.com/) 6 | exportable: true 7 | homepageUrl: http://n4no.com/ 8 | donationUrl: http://n4no.com/ 9 | filePaths: 10 | - license.txt 11 | - index.html 12 | - page.html 13 | - header.partial 14 | - footer.partial 15 | - assets/style.css 16 | - assets/favicon.png 17 | - assets/og-image.png 18 | 19 | dataContract: 20 | 21 | PAGES: 22 | sections: 23 | PAGES: 24 | properties: 25 | PAGES: 26 | type: (collection) 27 | defaultOccurrences: 1 28 | properties: 29 | TITLE: 30 | type: (text) 31 | defaultValue: Page 32 | FILE_PATH: 33 | type: (text) 34 | required: false 35 | CONTENT: 36 | type: (markdown) 37 | 38 | COMMON: 39 | _label: Common 40 | sections: 41 | META: 42 | _label: Meta 43 | properties: 44 | LANGUAGE: 45 | type: (choice) 46 | valuesSet: (iso6391Languages) 47 | defaultValue: en 48 | DIRECTION: 49 | type: (choice) 50 | valuesSet: (direction) 51 | defaultValue: auto 52 | TITLE: 53 | type: (text) 54 | defaultValue: My Page Title 55 | _label: Page Title 56 | DESCRIPTION: 57 | type: (text) 58 | defaultValue: My Page Description 59 | _label: Page Description 60 | FAVICON: 61 | type: (image) 62 | width: 32 63 | height: 32 64 | defaultFilePath: assets/favicon.png 65 | OG_IMAGE: 66 | type: (image) 67 | width: 1200 68 | height: 630 69 | defaultFilePath: assets/og-image.png 70 | _label: Open Graph Image 71 | PRE_HTML: 72 | type: (html) 73 | _label: Pre HTML 74 | _description: i.e. cookie banner 75 | required: false 76 | FOOTER: 77 | properties: 78 | COPYRIGHT: 79 | type: (text) 80 | defaultValue: All rights reserved 81 | POWERED_BY: 82 | type: (boolean) 83 | defaultValue: true 84 | POST_HTML: 85 | type: (html) 86 | _label: Post HTML 87 | _description: i.e. plugins, tracking 88 | required: false 89 | 90 | pages: 91 | INDEX: 92 | filePath: index.html 93 | templateFilePath: index.html 94 | 95 | PAGE: 96 | filePath: page.html 97 | templateFilePath: page.html 98 | multiplier: 99 | dataPath: PAGES.PAGES.PAGES 100 | fileNameDataPath: FILE_PATH 101 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/dist/zone-testing'; 2 | import { getTestBed } from '@angular/core/testing'; 3 | import { 4 | BrowserDynamicTestingModule, 5 | platformBrowserDynamicTesting 6 | } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | declare const require: { 9 | context(path: string, deep?: boolean, filter?: RegExp): { 10 | keys(): string[]; 11 | (id: string): T; 12 | }; 13 | }; 14 | 15 | getTestBed().initTestEnvironment( 16 | BrowserDynamicTestingModule, 17 | platformBrowserDynamicTesting() 18 | ); 19 | const context = require.context('./', true, /\.spec\.ts$/); 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /t3mpl-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4rtaz/t3mpl-editor/9e567f1fdd858dfca13503392c491865dee44252/t3mpl-editor.png -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "resolveJsonModule": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "module": "es2020", 15 | "lib": [ 16 | "es2018", 17 | "dom" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine" 7 | ] 8 | }, 9 | "files": [ 10 | "src/test.ts", 11 | "src/polyfills.ts" 12 | ], 13 | "include": [ 14 | "src/**/*.spec.ts", 15 | "src/**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "align": { 8 | "options": [ 9 | "parameters", 10 | "statements" 11 | ] 12 | }, 13 | "array-type": false, 14 | "arrow-return-shorthand": true, 15 | "curly": true, 16 | "deprecation": { 17 | "severity": "warning" 18 | }, 19 | "eofline": true, 20 | "import-blacklist": [ 21 | true, 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": { 26 | "options": [ 27 | "tabs" 28 | ] 29 | }, 30 | "max-classes-per-file": false, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-empty": false, 55 | "no-inferrable-types": [ 56 | true, 57 | "ignore-params" 58 | ], 59 | "no-non-null-assertion": true, 60 | "no-redundant-jsdoc": true, 61 | "no-switch-case-fall-through": true, 62 | "no-var-requires": false, 63 | "object-literal-key-quotes": [ 64 | true, 65 | "as-needed" 66 | ], 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "semicolon": { 72 | "options": [ 73 | "always" 74 | ] 75 | }, 76 | "space-before-function-paren": { 77 | "options": { 78 | "anonymous": "never", 79 | "asyncArrow": "always", 80 | "constructor": "never", 81 | "method": "never", 82 | "named": "never" 83 | } 84 | }, 85 | "typedef": [ 86 | false 87 | ], 88 | "typedef-whitespace": { 89 | "options": [ 90 | { 91 | "call-signature": "nospace", 92 | "index-signature": "nospace", 93 | "parameter": "nospace", 94 | "property-declaration": "nospace", 95 | "variable-declaration": "nospace" 96 | }, 97 | { 98 | "call-signature": "onespace", 99 | "index-signature": "onespace", 100 | "parameter": "onespace", 101 | "property-declaration": "onespace", 102 | "variable-declaration": "onespace" 103 | } 104 | ] 105 | }, 106 | "variable-name": { 107 | "options": [ 108 | "ban-keywords", 109 | "check-format", 110 | "allow-pascal-case" 111 | ] 112 | }, 113 | "whitespace": { 114 | "options": [ 115 | "check-branch", 116 | "check-decl", 117 | "check-operator", 118 | "check-separator", 119 | "check-type", 120 | "check-typecast" 121 | ] 122 | }, 123 | "component-class-suffix": true, 124 | "contextual-lifecycle": true, 125 | "directive-class-suffix": true, 126 | "no-conflicting-lifecycle": true, 127 | "no-host-metadata-property": true, 128 | "no-input-rename": true, 129 | "no-inputs-metadata-property": true, 130 | "no-output-native": true, 131 | "no-output-on-prefix": true, 132 | "no-output-rename": true, 133 | "no-outputs-metadata-property": true, 134 | "template-banana-in-box": true, 135 | "template-no-negated-async": true, 136 | "use-lifecycle-interface": true, 137 | "use-pipe-transform-interface": true, 138 | "directive-selector": [ 139 | true, 140 | "attribute", 141 | "app", 142 | "camelCase" 143 | ], 144 | "component-selector": [ 145 | true, 146 | "element", 147 | "app", 148 | "kebab-case" 149 | ] 150 | } 151 | } 152 | --------------------------------------------------------------------------------