├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json └── src ├── commands.js ├── consts.js ├── index.js ├── locale └── en.js ├── manager ├── index.js ├── pages.js ├── settings.js └── templates.js ├── storage ├── firestore.js ├── index.js ├── indexeddb.js └── remote.js ├── styles.scss └── utils ├── objsize.js ├── sort.js ├── timeago.js └── ui.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | private/ 3 | /locale 4 | node_modules/ 5 | *.log 6 | _index.html 7 | dist/ 8 | stats.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | private/ 3 | /locale 4 | node_modules/ 5 | *.log 6 | _index.html 7 | stats.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-current Grapesjs Template Manager 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grapesjs Project Manager 2 | 3 | > Requires GrapesJS v0.19.4 or higher. 4 | 5 | Project, template and page manager for grapesjs. This version makes use of the [`PageManager`](https://github.com/artf/grapesjs/pull/3411) and has different plugin and package name, the previous version which doesn't make use of the `PageManager` can be found [here](https://github.com/Ju99ernaut/grapesjs-template-manager/tree/template-manager). 6 | 7 | | Project | Project settings | 8 | |---------|------------------| 9 | | ![Screenshot (224)](https://user-images.githubusercontent.com/48953676/130074718-0e50d99a-d004-41e0-890c-66f05175e45c.png) | ![Screenshot (226)](https://user-images.githubusercontent.com/48953676/130074800-075eab50-3059-493d-afa7-0b9f8af9fdf6.png) | 10 | 11 | | Pages | Page settings | 12 | |-------|---------------| 13 | | ![Screenshot (225)](https://user-images.githubusercontent.com/48953676/130074843-81c120f9-37a0-4ee1-b8d4-019a16de6a46.png) | ![Screenshot (227)](https://user-images.githubusercontent.com/48953676/130074992-12a1774a-0a85-4e4f-8a14-1c95e0a7a7b6.png) | 14 | 15 | ### HTML 16 | ```html 17 | 18 | 19 | 20 | 21 | 22 |
23 | ``` 24 | 25 | ### JS 26 | ```js 27 | const editor = grapesjs.init({ 28 | container: '#gjs', 29 | height: '100%', 30 | fromElement: true, 31 | pageManager: true, // This should be set to true 32 | storageManager: { 33 | type: 'indexeddb', 34 | // ... 35 | }, 36 | plugins: ['grapesjs-project-manager'], 37 | }); 38 | 39 | // Running commands from panels 40 | const pn = editor.Panels; 41 | pn.addButton('options', { 42 | id: 'open-templates', 43 | className: 'fa fa-folder-o', 44 | attributes: { 45 | title: 'Open projects and templates' 46 | }, 47 | command: 'open-templates', //Open modal 48 | }); 49 | pn.addButton('views', { 50 | id: 'open-pages', 51 | className: 'fa fa-file-o', 52 | attributes: { 53 | title: 'Take Screenshot' 54 | }, 55 | command: 'open-pages', 56 | togglable: false 57 | }); 58 | ``` 59 | 60 | ### CSS 61 | ```css 62 | body, html { 63 | margin: 0; 64 | height: 100%; 65 | } 66 | ``` 67 | 68 | 69 | ## Summary 70 | 71 | * Plugin name: `grapesjs-project-manager` 72 | * Commands 73 | * `open-templates` 74 | * `open-pages` 75 | * `open-settings` 76 | * `get-uuidv4` 77 | * `take-screenshot` 78 | * `save-as-template` 79 | * `delete-template` 80 | * Storages 81 | * `indexeddb` 82 | * `firestore` 83 | * `rest-api` 84 | 85 | ## Options 86 | 87 | | Option | Description | Default | 88 | |-|-|- 89 | | `dbName` | Database name | `gjs` | 90 | | `objectStoreName` | Collection name | `templates` | 91 | | `loadFirst` | Load first template in storage | `true` | 92 | | `customLoad` | Use custom onload function(skips default onload steps), `(ed, cs) => ...` | `false` | 93 | | `components` | Default components since `fromElement` is not supported | `undefined` | 94 | | `style` | Default style since `fromElement` is not supported | `undefined` | 95 | | `indexeddbVersion` | IndexedDB schema version | `5` | 96 | | `onDelete` | On successful template deletion | `Function(Check source)` | 97 | | `onDeleteAsync` | Handle promise from storage delete | `Function(Check source)` | 98 | | `onUpdateAsync` | Handle promise from storage update | `Function(Check source)` | 99 | | `onScreenshotAsync` | Handle promise from screenshot | `Function(Check source)` | 100 | | `onScreenShotError` | On error capturing screenshot | `Function(Check source)` | 101 | | `onThumbnail` | Handle thumbnail data | `Function(Check source)` | 102 | | `quality` | Generated screenshot quality | `.01` | 103 | | `mdlTitle` | Modal title | `Project Manager` | 104 | | `apiKey` | `Firebase` API key | ` ` | 105 | | `authDomain` | `Firebase` Auth domain | ` ` | 106 | | `projectId` | `Cloud Firestore` project ID | ` ` | 107 | | `firebaseConfig` | Extra firebase app credentials | `{}` | 108 | | `enableOffline` | Enable `Firestore` support for offline data persistence | `true` | 109 | | `settings` | `Firestore` database settings | `{ timestampsInSnapshots: true }` | 110 | | `uuidInPath` | Add uuid as path parameter on store for `rest-api`(useful for validation) | `true` | 111 | | `size` | Display estimated project sizes | `true` | 112 | | `currentPageOpen` | Send feedback when open is clicked on current page | `check source` | 113 | | `ì18n` | I18n object containing language [more info](https://grapesjs.com/docs/modules/I18n.html#configuration) | `{}` | 114 | 115 | * Setting `loadFirst` to `false` prevents overwritting the contents of the editor with the contents of the first template in storage. 116 | * Only use options for `Firebase` when using `Cloud Firestore` storage. 117 | * `dbName` and `indexeddbVersion` only apply to `indexddb` storage. 118 | * `objectStoreName` acts as collection name for both `firestore` and ` indexeddb`. 119 | * When `uuidInPath` is set to `false` the store request will be `http://endpoint/store/` instead of `http://endpoint/store/{uuid}` 120 | 121 | ## Local/IndexedDB 122 | 123 | ```js 124 | window.editor = grapesjs.init({ 125 | container: '#gjs', 126 | // ... 127 | pageManager: true, 128 | storageManager: { 129 | type: 'indexeddb' 130 | }, 131 | plugins: ['grapesjs-project-manager'], 132 | pluginsOpts: { 133 | 'grapesjs-project-manager': { /* Options */ } 134 | } 135 | }); 136 | ``` 137 | 138 | ## Firestore 139 | 140 | > Tested on firebase v8+. Firebase v9+ not yet supported. 141 | 142 | Configure firestore access rules for your app. 143 | Add libraries to `head` of document: 144 | 145 | ```html 146 | 147 | 148 | 150 | 151 | ``` 152 | 153 | Add credentials: 154 | 155 | ```js 156 | window.editor = grapesjs.init({ 157 | container: '#gjs', 158 | // ... 159 | pageManager: true, 160 | storageManager: { 161 | type: 'firestore' 162 | }, 163 | plugins: ['grapesjs-project-manager'], 164 | pluginsOpts: { 165 | 'grapesjs-project-manager': { 166 | // Firebase API key 167 | apiKey: 'FIREBASE_API_KEY', 168 | // Firebase Auth domain 169 | authDomain: 'app-id-00a00.firebaseapp.com', 170 | // Cloud Firestore project ID 171 | projectId: 'app-id-00a00', 172 | } 173 | } 174 | }); 175 | ``` 176 | 177 | ## Remote/REST-API 178 | 179 | Example backend https://github.com/Ju99ernaut/gjs-api 180 | 181 | ```js 182 | window.editor = grapesjs.init({ 183 | container: '#gjs', 184 | // ... 185 | pageManager: true, 186 | storageManager: { 187 | type: 'rest-api', 188 | // the URIs below can be the same depending on your API design 189 | options: { 190 | remote: { 191 | urlStore: 'https://endpoint/store/',// POST 192 | urlLoad: 'https://endpoint/load/',// GET 193 | urlDelete: 'https://endpoint/delete/',// DELETE 194 | // ... 195 | } 196 | } 197 | }, 198 | plugins: ['grapesjs-project-manager'], 199 | pluginsOpts: { 200 | 'grapesjs-project-manager': { /* options */ } 201 | } 202 | }); 203 | ``` 204 | 205 | The backend schema can be something like: 206 | 207 | `GET` `https://api/templates/` load all templates 208 | 209 | Returns 210 | ```json 211 | [ 212 | { 213 | "id": "UUIDv4", 214 | "name": "Page name", 215 | "template": false, 216 | "thumbnail": "", 217 | "description": "No description", 218 | "assets": "[]", 219 | "pages": "[]", 220 | "styles": "[]", 221 | "updated_at": "" 222 | } 223 | ] 224 | ``` 225 | 226 | `POST` `https://api/templates/{idx: UUIDv4}` store or update template 227 | 228 | Expects 229 | ```json 230 | { 231 | "id": "UUIDv4", 232 | "name": "Page name", 233 | "template": false, 234 | "thumbnail": "", 235 | "description": "No description", 236 | "assets": "[]", 237 | "pages": "[]", 238 | "styles": "[]", 239 | "updated_at": "" 240 | } 241 | ``` 242 | 243 | `GET` `https://api/templates/{idx: UUIDv4}` load template 244 | 245 | Returns 246 | ```json 247 | { 248 | "id": "UUIDv4", 249 | "name": "Page name", 250 | "template": false, 251 | "thumbnail": "", 252 | "description": "No description", 253 | "assets": "[]", 254 | "pages": "[]", 255 | "styles": "[]", 256 | "updated_at": "" 257 | } 258 | ``` 259 | 260 | `DELETE` `https://api/templates/{idx: UUIDv4}` delete template 261 | 262 | Which would have the following setup: 263 | ```js 264 | window.editor = grapesjs.init({ 265 | container: '#gjs', 266 | // ... 267 | storageManager: { 268 | type: 'rest-api', 269 | // the URIs below can be the same depending on your API design 270 | options:{ 271 | remote:{ 272 | urlStore: 'https://api/templates/',// POST 273 | urlLoad: 'https://api/templates/',// GET 274 | urlDelete: 'https://api/templates/',// DELETE 275 | } 276 | } 277 | }, 278 | plugins: ['grapesjs-template-manager'], 279 | pluginsOpts: { 280 | 'grapesjs-template-manager': { /* options */ } 281 | } 282 | }); 283 | ``` 284 | 285 | All the fields are generated from the editor so you just need to setup your API to receive and return data in that format. I'd recommend you check the network tab so you get a more accurate format for the payloads. 286 | 287 | ## Download 288 | 289 | * CDN 290 | * `https://unpkg.com/grapesjs-project-manager` 291 | * NPM 292 | * `npm i grapesjs-project-manager` 293 | * GIT 294 | * `git clone https://github.com/Ju99ernaut/grapesjs-template-manager.git` 295 | 296 | 297 | 298 | ## Usage 299 | 300 | Directly in the browser 301 | ```html 302 | 303 | 304 | 305 | 306 | 307 |
308 | 309 | 324 | ``` 325 | 326 | Modern javascript 327 | ```js 328 | import grapesjs from 'grapesjs'; 329 | import plugin from 'grapesjs-project-manager'; 330 | import 'grapesjs/dist/css/grapes.min.css'; 331 | import 'grapesjs-project-manager/dist/grapesjs-project-manager.min.css'; 332 | 333 | const editor = grapesjs.init({ 334 | container : '#gjs', 335 | // ... 336 | pageManager: true, 337 | storageManager: { 338 | type: 'indexeddb', 339 | // ... 340 | }, 341 | plugins: [plugin], 342 | pluginsOpts: { 343 | [plugin]: { /* options */ } 344 | } 345 | // or 346 | plugins: [ 347 | editor => plugin(editor, { /* options */ }), 348 | ], 349 | }); 350 | ``` 351 | 352 | ## Development 353 | 354 | Clone the repository 355 | 356 | ```sh 357 | $ git clone https://github.com/Ju99ernaut/grapesjs-template-manager.git 358 | $ cd grapesjs-template-manager 359 | ``` 360 | 361 | Install dependencies 362 | 363 | ```sh 364 | $ npm i 365 | ``` 366 | 367 | Build css or watch scss 368 | 369 | ```sh 370 | $ npm run build:css 371 | ``` 372 | 373 | `OR` 374 | 375 | ``` 376 | $ npm run watch:scss 377 | ``` 378 | 379 | Start the dev server 380 | 381 | ```sh 382 | $ npm start 383 | ``` 384 | 385 | Build the source 386 | 387 | ```sh 388 | $ npm run build 389 | ``` 390 | 391 | 392 | 393 | ## License 394 | 395 | MIT 396 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Grapesjs Project Manager 7 | 8 | 9 | 10 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | This is a demo content from _index.html. You can use this template file for 24 | development purpose. It won't be stored in your git repository 25 |
26 |
27 | 28 | 29 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grapesjs-project-manager", 3 | "version": "2.0.6", 4 | "description": "Grapesjs Project Manager", 5 | "main": "dist/grapesjs-project-manager.min.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Ju99ernaut/grapesjs-template-manager.git" 9 | }, 10 | "scripts": { 11 | "start": "grapesjs-cli serve", 12 | "build": "grapesjs-cli build", 13 | "build:css": "sass src/styles.scss dist/grapesjs-project-manager.min.css --style compressed", 14 | "watch:scss": "sass --watch src/styles.scss dist/grapesjs-project-manager.min.css --style compressed", 15 | "bump": "npm version patch -m 'Bump v%s'" 16 | }, 17 | "keywords": [ 18 | "grapesjs", 19 | "plugin" 20 | ], 21 | "devDependencies": { 22 | "grapesjs-cli": "^1.0.14", 23 | "sass": "^1.32.8" 24 | }, 25 | "author": "Brendon Ngirazi", 26 | "license": "MIT", 27 | "dependencies": { 28 | "dom-to-image": "^2.6.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/commands.js: -------------------------------------------------------------------------------- 1 | import domtoimage from 'dom-to-image'; 2 | 3 | export default (editor, opts = {}) => { 4 | const cm = editor.Commands; 5 | const cs = editor.Storage.getCurrentStorage(); 6 | const mdl = editor.Modal; 7 | const pfx = editor.getConfig('stylePrefix'); 8 | const mdlClass = `${pfx}mdl-dialog-tml`; 9 | const mdlClassMd = `${pfx}mdl-dialog-md`; 10 | 11 | editor.domtoimage = domtoimage; 12 | 13 | cm.add('open-templates', { 14 | run(editor, sender) { 15 | const mdlDialog = document.querySelector(`.${pfx}mdl-dialog`); 16 | mdlDialog.classList.add(mdlClass); 17 | sender?.set && sender.set('active'); 18 | mdl.setTitle(opts.mdlTitle); 19 | mdl.setContent(editor.TemplateManager.render()); 20 | mdl.open(); 21 | mdl.getModel().once('change:open', () => { 22 | mdlDialog.classList.remove(mdlClass); 23 | }); 24 | } 25 | }); 26 | 27 | cm.add('open-settings', { 28 | run(editor, sender) { 29 | const mdlDialog = document.querySelector(`.${pfx}mdl-dialog`); 30 | mdlDialog.classList.add(mdlClassMd); 31 | sender?.set && sender.set('active'); 32 | mdl.setTitle(opts.mdlTitle); 33 | mdl.setContent(editor.SettingsApp.render()); 34 | mdl.open(); 35 | mdl.getModel().once('change:open', () => { 36 | mdlDialog.classList.remove(mdlClassMd); 37 | }); 38 | } 39 | }); 40 | 41 | cm.add('open-pages', { 42 | run(editor) { 43 | editor.PagesApp.showPanel(); 44 | }, 45 | stop(editor) { 46 | editor.PagesApp.hidePanel(); 47 | } 48 | }) 49 | 50 | //some magic from gist.github.com/jed/982883 51 | const uuidv4 = () => ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => 52 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 53 | ); 54 | 55 | const getJpeg = async (node, options = {}, clb, clbErr) => { 56 | try { 57 | const dataUrl = await opts.onScreenshotAsync(domtoimage.toJpeg(node, options)); 58 | clb && clb(dataUrl); 59 | } catch (err) { 60 | clbErr && clbErr(err) 61 | } 62 | }; 63 | 64 | cm.add('get-uuidv4', () => { 65 | if (crypto) { 66 | return crypto.randomUUID ? crypto.randomUUID() : uuidv4(); 67 | } 68 | }); 69 | 70 | cm.add('take-screenshot', (editor, s, options = { clb(d) { return d } }) => { 71 | const el = editor.getWrapper().getEl(); 72 | getJpeg(el, { 73 | quality: opts.quality, 74 | height: 1000, 75 | 'cacheBust': true, 76 | style: { 77 | 'background-color': 'white', 78 | ...editor.getWrapper().getStyle() 79 | }, 80 | }, options.clb, opts.onScreenshotError); 81 | }); 82 | 83 | cm.add('save-as-template', editor => { 84 | cs.setIsTemplate(true); 85 | editor.store(); 86 | }); 87 | 88 | cm.add('delete-template', async (editor) => { 89 | const res = await cs.delete(); 90 | opts.onDelete(res); 91 | }); 92 | } -------------------------------------------------------------------------------- /src/consts.js: -------------------------------------------------------------------------------- 1 | export const storageIDB = 'indexeddb', 2 | storageRemote = 'rest-api', 3 | storageFireStore = 'firestore', 4 | helpers = { 5 | currentName: 'Default', 6 | currentId: 'uuidv4', 7 | currentThumbnail: '', 8 | isTemplate: false, 9 | description: 'No description', 10 | 11 | setId(id) { 12 | this.currentId = id; 13 | }, 14 | 15 | setName(name) { 16 | this.currentName = name; 17 | }, 18 | 19 | setThumbnail(thumbnail) { 20 | this.currentThumbnail = thumbnail; 21 | }, 22 | 23 | setIsTemplate(isTemplate) { 24 | this.isTemplate = !!isTemplate; 25 | }, 26 | 27 | setDescription(description) { 28 | this.description = description; 29 | }, 30 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import TemplateManager, { PagesApp, SettingsApp } from './manager'; 2 | import commands from './commands'; 3 | import storage from './storage'; 4 | import en from './locale/en'; 5 | 6 | export default (editor, opts = {}) => { 7 | const options = { 8 | ...{ 9 | // default options 10 | // Allow migration of projects using deprecated storage prefix 11 | legacyPrefix: '', 12 | // Database name 13 | dbName: 'gjs', 14 | 15 | // Collection name 16 | objectStoreName: 'projects', 17 | 18 | // Load first template in storage 19 | loadFirst: true, 20 | 21 | // Custom load 22 | customLoad: false, 23 | 24 | // Add uuid as path parameter to store path for rest-api 25 | uuidInPath: true, 26 | 27 | // Indexeddb version schema 28 | indexeddbVersion: 6, 29 | 30 | // Custom delete function 31 | delete: false, 32 | 33 | // Confirm delete project 34 | confirmDeleteProject() { 35 | return confirm('Are you sure to delete this project') 36 | }, 37 | 38 | // Confirm delete page 39 | confirmDeletePage() { 40 | return confirm('Are you sure to delete this page') 41 | }, 42 | 43 | // When template or page is deleted 44 | onDelete(res) { 45 | console.log('Deleted:', res) 46 | }, 47 | 48 | // Handle promise from delete 49 | onDeleteAsync(del) { 50 | return del; 51 | }, 52 | 53 | // Custom update function 54 | update: false, 55 | 56 | // Handle promise from update 57 | onUpdateAsync(up) { 58 | return up; 59 | }, 60 | 61 | // Handle promise from screenshot 62 | onScreenshotAsync(shot) { 63 | return shot; 64 | }, 65 | 66 | // On screenshot error 67 | onScreenshotError(err) { 68 | console.log(err) 69 | }, 70 | 71 | // Handle built-in thumbnail generation 72 | // By default it just sets the url as the base64 encoded image which may be too large to store in a database 73 | // You might want to upload this somewhere 74 | onThumbnail(dataUrl, $input) { 75 | $input.val(dataUrl); 76 | }, 77 | 78 | // Quality of screenshot image from 0 to 1, more quality increases the image size 79 | quality: .01, 80 | 81 | // Content for templates modal title 82 | mdlTitle: 'Project Manager', 83 | 84 | // Show when no pages yet pages 85 | nopages: '
No Projects Yet
', 86 | 87 | // Firebase API key 88 | apiKey: '', 89 | 90 | // Firebase Auth domain 91 | authDomain: '', 92 | 93 | // Cloud Firestore project ID 94 | projectId: '', 95 | 96 | // Enable support for offline data persistence 97 | enableOffline: true, 98 | 99 | // Firebase app config 100 | firebaseConfig: {}, 101 | 102 | // Database settings (https://firebase.google.com/docs/reference/js/firebase.firestore.Settings) 103 | settings: { timestampsInSnapshots: true }, 104 | 105 | // Show estimated project statistics 106 | size: false, 107 | 108 | // Send feedback when open is clicked on current page 109 | currentPageOpen() { 110 | console.log('Current page already open') 111 | }, 112 | 113 | i18n: {}, 114 | }, 115 | ...opts, 116 | }; 117 | 118 | editor.I18n.addMessages({ 119 | en, 120 | ...options.i18n, 121 | }); 122 | 123 | // Init and add dashboard object to editor 124 | editor.TemplateManager = new TemplateManager(editor, options); 125 | editor.PagesApp = new PagesApp(editor, options); 126 | editor.SettingsApp = new SettingsApp(editor, options); 127 | 128 | // Load commands 129 | commands(editor, options); 130 | 131 | // Load storages 132 | storage(editor, options); 133 | 134 | // Load page with index zero 135 | editor.on('load', async () => { 136 | const cs = editor.Storage.getCurrentStorage(); 137 | const { customLoad } = options; 138 | customLoad && typeof customLoad === 'function' && customLoad(editor, cs); 139 | if (!customLoad) { 140 | const res = await cs.loadAll(); 141 | const firstPage = res[0]; 142 | if (firstPage && options.loadFirst) { 143 | cs.setId(firstPage.id); 144 | cs.setName(firstPage.name); 145 | cs.setThumbnail(firstPage.thumbnail); 146 | cs.setIsTemplate(firstPage.template); 147 | await editor.load(); 148 | editor.stopCommand('sw-visibility'); 149 | editor.runCommand('sw-visibility'); 150 | } else { 151 | cs.setId(editor.runCommand('get-uuidv4')); 152 | cs.setName(`Default-${cs.currentId.substr(0, 7)}`); 153 | } 154 | } 155 | }); 156 | }; -------------------------------------------------------------------------------- /src/locale/en.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'grapesjs-project-manager': { 3 | templates: { 4 | all: 'All', 5 | templates: 'Templates', 6 | search: 'Search for sites by name or id', 7 | open: 'Open', 8 | new: 'Enter new page name', 9 | create: 'Create', 10 | help: 'Select a template, enter project name, then click create. If no template is selected a blank project will be created.', 11 | info: 'Site Info', 12 | updated: 'Last Updated', 13 | pages: 'Pages', 14 | created: 'Created At', 15 | size: 'Size', 16 | actions: 'Actions', 17 | titles: { 18 | open: 'Select to open site', 19 | info: 'Click to sort by site name', 20 | updated: 'Click to sort by last update date', 21 | pages: 'Click to sort by number of pages', 22 | created: 'Click to sort by site creation date', 23 | size: 'Click to sort by site size', 24 | actions: 'Click to sort by site name', 25 | delete: 'Delete', 26 | edit: 'Edit', 27 | } 28 | }, 29 | pages: { 30 | placeholder: 'page name', 31 | new: 'New Page +', 32 | }, 33 | settings: { 34 | save: 'Save', 35 | generate: 'Generate', 36 | help: 'Enter url, or generate thumbnail.', 37 | labels: { 38 | name: 'Name', 39 | thumbnail: 'Thumbnail', 40 | description: 'Description', 41 | template: 'Template' 42 | }, 43 | placeholders: { 44 | name: 'Name...', 45 | thumbnail: 'Thumbnail...', 46 | description: 'Description...', 47 | }, 48 | }, 49 | } 50 | } -------------------------------------------------------------------------------- /src/manager/index.js: -------------------------------------------------------------------------------- 1 | import TemplateManager from "./templates"; 2 | import PagesApp from "./pages"; 3 | import SettingsApp from "./settings"; 4 | 5 | export default TemplateManager; 6 | export { TemplateManager, PagesApp, SettingsApp }; -------------------------------------------------------------------------------- /src/manager/pages.js: -------------------------------------------------------------------------------- 1 | import UI from '../utils/ui'; 2 | 3 | export default class PagesApp extends UI { 4 | constructor(editor, opts = {}) { 5 | super(editor, opts); 6 | this.addPage = this.addPage.bind(this); 7 | this.selectPage = this.selectPage.bind(this); 8 | this.removePage = this.removePage.bind(this); 9 | this.isSelected = this.isSelected.bind(this); 10 | this.handleNameInput = this.handleNameInput.bind(this); 11 | this.openEdit = this.openEdit.bind(this); 12 | 13 | /* Set initial app state */ 14 | this.state = { 15 | editablePageId: '', 16 | isShowing: true, 17 | nameText: '', 18 | pages: [], 19 | loading: false 20 | }; 21 | } 22 | 23 | get editableId() { 24 | return this.state.editablePageId; 25 | } 26 | 27 | onRender() { 28 | const { pm, setState, editor } = this; 29 | setState({ 30 | loading: true 31 | }); 32 | setState({ 33 | pages: [...pm.getAll()] 34 | }); 35 | editor.on('page', () => { 36 | setState({ 37 | pages: [...pm.getAll()] 38 | }) 39 | }); 40 | setState({ 41 | loading: false 42 | }); 43 | } 44 | 45 | isSelected(page) { 46 | return this.pm.getSelected().id === page.id; 47 | } 48 | 49 | selectPage(e) { 50 | this.pm.select(e.currentTarget.dataset.key); 51 | this.update(); 52 | } 53 | 54 | removePage(e) { 55 | if (this.opts.confirmDeleteProject()) { 56 | this.pm.remove(e.currentTarget.dataset.key); 57 | this.update(); 58 | } 59 | } 60 | 61 | openEdit(e) { 62 | const { editor } = this; 63 | this.setStateSilent({ 64 | editablePageId: e.currentTarget.dataset.key 65 | }); 66 | editor.Modal.close(); 67 | editor.SettingsApp.setTab('page'); 68 | editor.runCommand('open-settings'); 69 | } 70 | 71 | editPage(id, name) { 72 | const currentPage = this.pm.get(id); 73 | currentPage?.set('name', name); 74 | this.update() 75 | } 76 | 77 | addPage() { 78 | const { pm } = this; 79 | const { nameText } = this.state 80 | if (!nameText) return; 81 | pm.add({ 82 | name: nameText, 83 | component: '' 84 | }); 85 | this.update(); 86 | } 87 | 88 | handleNameInput(e) { 89 | this.setStateSilent({ 90 | nameText: e.target.value.trim() 91 | }) 92 | } 93 | 94 | renderPagesList() { 95 | const { pages, loading } = this.state; 96 | const { opts, isSelected } = this; 97 | 98 | if (loading) return opts.loader || '
Loading pages...
'; 99 | 100 | return pages.map((page, i) => `
105 | 106 | ${page.get('name') || page.id} 107 | ${isSelected(page) || page.get('internal') ? '' : ``} 108 | ${page.get('internal') ? '' : ``} 109 |
`).join("\n"); 110 | } 111 | 112 | update() { 113 | this.$el?.find('.pages').html(this.renderPagesList()); 114 | this.$el?.find('.page').on('click', this.selectPage); 115 | this.$el?.find('.page-edit').on('click', this.openEdit); 116 | this.$el?.find('.page-close').on('click', this.removePage); 117 | } 118 | 119 | render() { 120 | const { $, editor } = this; 121 | 122 | // Do stuff on render 123 | this.onRender(); 124 | this.$el?.remove(); 125 | 126 | const cont = $(`
127 |
128 | ${this.renderPagesList()} 129 |
130 |
131 | 136 |
137 |
138 | ${editor.I18n.t('grapesjs-project-manager.pages.new')} 139 |
140 |
`); 141 | cont.find('.add-page').on('click', this.addPage); 142 | cont.find('input').on('change', this.handleNameInput); 143 | 144 | this.$el = cont; 145 | return cont; 146 | } 147 | 148 | get findPanel() { 149 | return this.editor.Panels.getPanel('views-container'); 150 | } 151 | 152 | showPanel() { 153 | this.state.isShowing = true; 154 | this.findPanel?.set('appendContent', this.render()).trigger('change:appendContent'); 155 | this.update(); 156 | } 157 | 158 | hidePanel() { 159 | this.state.isShowing = false; 160 | this.render(); 161 | } 162 | } -------------------------------------------------------------------------------- /src/manager/settings.js: -------------------------------------------------------------------------------- 1 | import UI from '../utils/ui'; 2 | 3 | export default class SettingsApp extends UI { 4 | constructor(editor, opts = {}) { 5 | super(editor, opts); 6 | this.handleSave = this.handleSave.bind(this); 7 | this.handleThumbnail = this.handleThumbnail.bind(this); 8 | this.handleThumbnailInput = this.handleThumbnailInput.bind(this); 9 | 10 | /* Set initial app state */ 11 | this.state = { 12 | tab: 'page', 13 | loading: false 14 | }; 15 | } 16 | 17 | setTab(tab) { 18 | this.state.tab = tab; 19 | } 20 | 21 | update() { 22 | const { $el } = this; 23 | $el?.find('#settings').html(this.renderSettings()); 24 | $el?.find('#generate').on('click', this.handleThumbnail); 25 | $el?.find('input#thumbnail').on('change', this.handleThumbnailInput); 26 | } 27 | 28 | onRender() { 29 | const { setState } = this; 30 | setState({ 31 | loading: true 32 | }); 33 | //? Setup code here 34 | setState({ 35 | loading: false 36 | }); 37 | } 38 | 39 | handleSave(e) { 40 | const { $el, editor } = this; 41 | const { tab } = this.state; 42 | if (tab === 'page') { 43 | const id = editor.PagesApp.editableId; 44 | const name = $el?.find('input.name').val().trim(); 45 | id && editor.PagesApp.editPage(id, name); 46 | } else { 47 | const id = editor.TemplateManager.editableId; 48 | const thumbnail = $el?.find('input.thumbnail').val().trim(); 49 | const name = $el?.find('input.name').val().trim(); 50 | const description = $el?.find('input.desc').val().trim(); 51 | const template = $el?.find('input.template').get(0).checked; 52 | id && editor.TemplateManager.handleEdit({ id, thumbnail, name, description, template }); 53 | } 54 | editor.Modal.close(); 55 | } 56 | 57 | handleThumbnail(e) { 58 | const { editor, $el, opts } = this; 59 | editor.runCommand('take-screenshot', { 60 | clb(dataUrl) { 61 | $el?.find('img').attr('src', dataUrl); 62 | opts.onThumbnail(dataUrl, $el?.find('input.thumbnail')); 63 | } 64 | }) 65 | } 66 | 67 | handleThumbnailInput(e) { 68 | this.$el?.find('img').attr('src', e.target.value.trim()); 69 | } 70 | 71 | renderSettings() { 72 | const { tab, loading } = this.state; 73 | const { opts, pfx, pm, editor } = this; 74 | 75 | if (loading) return opts.loader || '
Loading settings...
'; 76 | 77 | if (tab === 'page') { 78 | const page = pm.get(editor.PagesApp.editableId); 79 | const value = page?.get('name') || page?.id || ''; 80 | return ` 83 |
84 | 88 |
` 89 | } else { 90 | const clb = site => site.id === editor.TemplateManager.editableId; 91 | const site = editor.TemplateManager.allSites.find(clb); 92 | return `
93 | ${editor.I18n.t('grapesjs-project-manager.settings.help')} 94 |
95 | 98 |
99 | 105 |
106 |
107 |
108 | screenshot 109 |
110 | 113 |
114 | 117 |
118 | 124 |
125 | 128 |
129 | 135 |
136 |
137 | 138 | 141 |
` 142 | } 143 | } 144 | 145 | render() { 146 | const { $, editor } = this; 147 | 148 | // Do stuff on render 149 | this.onRender(); 150 | this.$el?.remove(); 151 | 152 | const cont = $(`
153 |
154 | ${this.renderSettings()} 155 |
156 |
157 | 160 |
161 |
`); 162 | cont.find('#save').on('click', this.handleSave); 163 | cont.find('#generate').on('click', this.handleThumbnail); 164 | cont.find('input#thumbnail').on('change', this.handleThumbnailInput); 165 | 166 | this.$el = cont; 167 | return cont; 168 | } 169 | } -------------------------------------------------------------------------------- /src/manager/templates.js: -------------------------------------------------------------------------------- 1 | import ago from '../utils/timeago'; 2 | import UI from '../utils/ui'; 3 | import objSize from '../utils/objsize'; 4 | import { sortByDate, sortByName, sortByPages, sortBySize, matchText } from '../utils/sort'; 5 | 6 | export default class TemplateManager extends UI { 7 | constructor(editor, opts = {}) { 8 | super(editor, opts); 9 | this.handleSort = this.handleSort.bind(this); 10 | this.handleFilterInput = this.handleFilterInput.bind(this); 11 | this.handleNameInput = this.handleNameInput.bind(this); 12 | this.handleOpen = this.handleOpen.bind(this); 13 | this.handleCreate = this.handleCreate.bind(this); 14 | this.handleDelete = this.handleDelete.bind(this); 15 | this.openEdit = this.openEdit.bind(this); 16 | 17 | /* Set initial app state */ 18 | this.state = { 19 | editableProjectId: '', 20 | projectId: '', 21 | tab: 'pages', 22 | sites: [], 23 | nameText: '', 24 | filterText: '', 25 | loading: false, 26 | sortBy: 'published_at', 27 | sortOrder: 'desc' 28 | }; 29 | } 30 | 31 | get editableId() { 32 | return this.state.editableProjectId; 33 | } 34 | 35 | get allSites() { 36 | return this.state.sites; 37 | } 38 | 39 | get allSitesSize() { 40 | return objSize(this.state.sites); 41 | } 42 | 43 | async onRender() { 44 | const { setState, cs } = this; 45 | 46 | /* Set request loading state */ 47 | setState({ 48 | loading: true 49 | }); 50 | 51 | /* Fetch sites from storage API */ 52 | const sites = await cs.loadAll(); 53 | /* Set sites and turn off loading state */ 54 | setState({ 55 | sites, 56 | loading: false 57 | }); 58 | } 59 | 60 | handleFilterInput(e) { 61 | this.setState({ 62 | filterText: e.target.value.trim() 63 | }); 64 | } 65 | 66 | handleNameInput(e) { 67 | this.setStateSilent({ 68 | nameText: e.target.value.trim() 69 | }) 70 | } 71 | 72 | handleSort(e) { 73 | const { sortOrder } = this.state; 74 | if (e.target && e.target.dataset) { 75 | this.setState({ 76 | sortBy: e.target.dataset.sort, 77 | // invert sort order 78 | sortOrder: sortOrder === 'desc' ? 'asc' : 'desc' 79 | }); 80 | } 81 | } 82 | 83 | handleTabs(e) { 84 | const { target } = e; 85 | const { $el, pfx, $ } = this; 86 | $el.find(`.${pfx}tablinks`).removeClass('active'); 87 | $(target).addClass('active'); 88 | if (target.id === 'pages') { 89 | this.setState({ tab: 'pages' }); 90 | } else { 91 | this.setState({ tab: 'templates' }); 92 | } 93 | } 94 | 95 | async handleOpen(e) { 96 | const { editor, cs } = this; 97 | const { projectId } = this.state; 98 | if (!projectId || projectId === cs.currentId) { 99 | this.opts.currentPageOpen() 100 | return; 101 | } 102 | cs.setId(projectId); 103 | const res = await editor.load(); 104 | cs.setName(res.name); 105 | cs.setThumbnail(res.thumbnail || ''); 106 | cs.setIsTemplate(res.template); 107 | cs.setDescription(res.description || 'No description'); 108 | editor.Modal.close(); 109 | } 110 | 111 | async handleCreate(e) { 112 | const { editor, cs } = this; 113 | const { projectId, nameText } = this.state; 114 | const id = editor.runCommand('get-uuidv4'); 115 | const name = nameText || 'New-' + id.substring(0, 8); 116 | const def = { 117 | id, 118 | name, 119 | template: false, 120 | thumbnail: '', 121 | styles: '[]', 122 | description: 'No description', 123 | pages: `[{"id": "${crypto.randomUUID().substring(0, 13)}", "name": "index"}]`, 124 | styles: '[]', 125 | assets: '[]' 126 | }; 127 | if (!projectId) { 128 | cs.setId(id); 129 | await cs.store(def); 130 | cs.setIsTemplate(false); 131 | const res = await editor.load(); 132 | cs.setId(res.id); 133 | cs.setName(res.name); 134 | cs.setThumbnail(res.thumbnail || ''); 135 | cs.setDescription(res.description || 'No description'); 136 | editor.Modal.close(); 137 | } else { 138 | cs.setId(projectId); 139 | cs.setIsTemplate(false); 140 | const res = await editor.load(); 141 | cs.setId(id); 142 | cs.setName(name); 143 | cs.setThumbnail(res.thumbnail || ''); 144 | cs.setDescription(res.description || 'No description'); 145 | editor.Modal.close(); 146 | } 147 | } 148 | 149 | openEdit(e) { 150 | const { editor, setStateSilent } = this; 151 | setStateSilent({ 152 | editableProjectId: e.currentTarget.dataset.id 153 | }); 154 | editor.Modal.close(); 155 | editor.SettingsApp.setTab('project'); 156 | editor.runCommand('open-settings'); 157 | } 158 | 159 | handleEdit(data) { 160 | const { opts, cs, editor } = this; 161 | if (typeof opts.update === 'function') { 162 | opts.update({ ...data, updated_at: Date.now() }, editor); 163 | } else { 164 | opts.onUpdateAsync(cs.update({ ...data, updated_at: Date.now() })); 165 | } 166 | } 167 | 168 | async handleDelete(e) { 169 | const { cs, setState, opts } = this; 170 | if (opts.confirmDeleteProject()) { 171 | const toDel = e.currentTarget.dataset.id; 172 | if (typeof opts.delete === 'function') { 173 | opts.delete(toDel) 174 | } else { 175 | const res = await opts.onDeleteAsync(cs.delete(toDel)); 176 | opts.onDelete(res); 177 | } 178 | const sites = await cs.loadAll(); 179 | setState({ sites }); 180 | } 181 | } 182 | 183 | renderSiteList() { 184 | const { sites, tab, filterText, loading, sortBy, sortOrder } = this.state; 185 | const { pfx, opts, cs, editor } = this; 186 | 187 | if (loading) return opts.loader || '
Loading sites...
'; 188 | 189 | if (!sites.length) return opts.nosites || '
No Sites
'; 190 | 191 | let order 192 | if (sortBy === 'id') { 193 | order = sortByName(sortBy, sortOrder); 194 | } else if (sortBy === 'updated_at' || sortBy === 'created_at') { 195 | order = sortByDate(sortBy, sortOrder); 196 | } else if (sortBy === 'pages') { 197 | order = sortByPages(sortBy, sortOrder); 198 | } else if (sortBy === 'size') { 199 | order = sortBySize(sortOrder); 200 | } 201 | 202 | const sortedSites = sites.sort(order); 203 | 204 | let matchingSites = sortedSites.filter(site => { 205 | // No search query. Show all 206 | if (!filterText && tab === 'pages') { 207 | return true; 208 | } 209 | 210 | const { id, name, template } = site; 211 | if ( 212 | (matchText(filterText, id) || 213 | matchText(filterText, name)) && 214 | tab === 'pages' 215 | ) { 216 | return true; 217 | } 218 | 219 | if (tab === 'templates' && template) { 220 | return true; 221 | } 222 | 223 | // no match! 224 | return false; 225 | }) 226 | .map((site, i) => { 227 | const { 228 | id, 229 | name, 230 | description, 231 | thumbnail, 232 | created_at, 233 | updated_at 234 | } = site; 235 | const size = objSize(site); 236 | const _pages = site.pages ? site.pages : (opts.legacyPrefix ? site[`${opts.legacyPrefix}pages`] : []); 237 | const pages = typeof _pages === 'string' ? JSON.parse(_pages) : _pages; 238 | const time = updated_at ? ago(updated_at) : 'NA'; 239 | const createdAt = created_at ? ago(created_at) : 'NA'; 240 | const pageNames = pages.map(page => page.name).join(', '); 241 | return `
246 |
247 | 248 |
249 |
250 |

251 | ${name} 252 |

253 |
254 | ${description} 255 |
256 |
257 |
${time}
258 |
259 |
260 | ${pages.length || 1} 261 |
262 |
263 |
${createdAt}
264 | ${opts.size ? `
265 | ${size.toFixed(2)} KB 266 |
` : ''} 267 |
268 | 269 | ${!(cs.currentId === id) ? `` : ''} 270 |
271 |
`; 272 | }).join('\n'); 273 | 274 | if (!matchingSites.length) { 275 | if (tab === 'templates') return opts.nosites || '
No Templates Available.
'; 276 | matchingSites = `
277 |

278 | No '${filterText}' examples found. Clear your search and try again. 279 |

280 |
`; 281 | } 282 | return matchingSites; 283 | } 284 | 285 | renderSiteActions() { 286 | const { editor } = this; 287 | 288 | return this.state.tab === 'pages' ? 289 | `
290 | 294 | 297 |
` : 298 | `
299 | ${editor.I18n.t('grapesjs-project-manager.templates.help')} 300 |
301 |
302 | 306 | 309 |
`; 310 | } 311 | 312 | renderThumbnail(thumbnail, page) { 313 | const def = ``; 314 | if (thumbnail) return def; 315 | else if (page.html) return ` 316 | 317 |
318 | ${page.html + ''} 319 |
320 |
321 |
`; 322 | return def; 323 | } 324 | 325 | update() { 326 | this.$el?.find('#site-list').html(this.renderSiteList()); 327 | this.$el?.find('#tm-actions').html(this.renderSiteActions()); 328 | const sites = this.$el?.find('.site-wrapper'); 329 | const search = this.$el?.find('input.search'); 330 | const name = this.$el?.find('input.name'); 331 | this.setStateSilent({ projectId: '' }); 332 | if (sites) { 333 | sites.on('click', e => { 334 | sites.removeClass('selected'); 335 | this.$(e.currentTarget).addClass('selected'); 336 | this.setStateSilent({ projectId: e.currentTarget.dataset.id }); 337 | }); 338 | } 339 | if (search) { 340 | search.val(this.state.filterText); 341 | search.on('change', this.handleFilterInput); 342 | } 343 | if (name) { 344 | name.val(this.state.nameText); 345 | name.on('change', this.handleNameInput); 346 | } 347 | this.$el?.find('#open').on('click', this.handleOpen); 348 | this.$el?.find('#create').on('click', this.handleCreate); 349 | this.$el?.find('i.edit').on('click', this.openEdit); 350 | this.$el?.find('i.delete').on('click', this.handleDelete); 351 | } 352 | 353 | render() { 354 | const { $, pfx, opts, editor } = this; 355 | const { tab } = this.state 356 | 357 | // Do stuff on render 358 | this.onRender(); 359 | this.$el?.remove(); 360 | 361 | /* Show admin UI */ 362 | const cont = $(`
363 |
364 |
365 | 368 | 371 |
372 |
373 | ${this.renderSiteActions()} 374 |
375 |
376 |
381 | ${editor.I18n.t('grapesjs-project-manager.templates.info')} 382 |
383 |
387 |
392 | ${editor.I18n.t('grapesjs-project-manager.templates.updated')} 393 |
394 |
399 | ${editor.I18n.t('grapesjs-project-manager.templates.pages')} 400 |
401 |
406 | ${editor.I18n.t('grapesjs-project-manager.templates.created')} 407 |
408 | ${opts.size ? `
413 | ${editor.I18n.t('grapesjs-project-manager.templates.size')} 414 |
` : ''} 415 |
420 | ${editor.I18n.t('grapesjs-project-manager.templates.actions')} 421 |
422 |
423 |
424 | ${this.renderSiteList()} 425 |
426 |
427 |
`); 428 | cont.find('.header').on('click', this.handleSort); 429 | cont.find('#pages, #templates').on('click', this.handleTabs); 430 | 431 | this.$el = cont; 432 | return cont; 433 | } 434 | } -------------------------------------------------------------------------------- /src/storage/firestore.js: -------------------------------------------------------------------------------- 1 | import { storageFireStore, helpers } from '../consts'; 2 | 3 | export default (editor, opts = {}) => { 4 | const sm = editor.StorageManager; 5 | const storageName = storageFireStore; 6 | 7 | let db; 8 | let doc; 9 | let collection; 10 | const { apiKey, authDomain, projectId } = opts; 11 | const dbSettings = opts.settings; 12 | const onError = err => sm.onError(storageName, err.code || err); 13 | 14 | const getDoc = () => doc; 15 | 16 | const getAsyncCollection = () => { 17 | if (collection) return collection; 18 | if (!firebase.apps.length) { 19 | firebase.initializeApp({ apiKey, authDomain, projectId, ...opts.firebaseConfig }); 20 | db = firebase.firestore(); 21 | db.settings(dbSettings); 22 | } 23 | else { 24 | firebase.app(); 25 | db = firebase.firestore(); 26 | db.settings(dbSettings); 27 | } 28 | 29 | if (opts.enableOffline) { 30 | db.enablePersistence().catch(onError); 31 | } 32 | 33 | collection = db.collection(opts.objectStoreName); 34 | return collection; 35 | }; 36 | 37 | const getAsyncDoc = () => { 38 | const cll = getAsyncCollection(); 39 | const cs = editor.Storage.getCurrentStorage(); 40 | doc = cll.doc(cs.currentId); 41 | return doc; 42 | }; 43 | 44 | sm.add(storageName, { 45 | ...helpers, 46 | getDoc, 47 | 48 | setDocId(id) { 49 | this.currentId = id; 50 | }, 51 | 52 | async load(keys) { 53 | const _doc = getAsyncDoc(); 54 | const doc = await _doc.get(); 55 | return doc.exists ? doc.data() : {}; 56 | }, 57 | 58 | async loadAll() { 59 | const cll = getAsyncCollection(); 60 | const docs = await cll.get(); 61 | const data = []; 62 | docs.forEach(doc => data.push(doc.data())); 63 | return data; 64 | }, 65 | 66 | async store(data) { 67 | const cll = getAsyncCollection(); 68 | await cll.doc(data.id || this.currentId).set({ 69 | id: this.currentId, 70 | name: this.currentName, 71 | template: this.isTemplate, 72 | thumbnail: this.currentThumbnail, 73 | description: this.description, 74 | updated_at: Date.now(), 75 | ...data 76 | }); 77 | }, 78 | 79 | async update(data) { 80 | const { id, ..._data } = data; 81 | const cll = getAsyncCollection(); 82 | await cll.doc(id).set(_data, { merge: true }); 83 | }, 84 | 85 | async delete(index) { 86 | if (!index) { 87 | const _doc = getAsyncDoc(); 88 | await _doc.delete(); 89 | } else { 90 | const cll = getAsyncCollection(); 91 | await cll.doc(index).delete(); 92 | } 93 | } 94 | }); 95 | } -------------------------------------------------------------------------------- /src/storage/index.js: -------------------------------------------------------------------------------- 1 | import indexeddb from './indexeddb'; 2 | import remote from './remote'; 3 | import firestore from './firestore'; 4 | 5 | export default (editor, opts = {}) => { 6 | // Load indexeddb storage 7 | indexeddb(editor, opts); 8 | 9 | // Load remote storage 10 | remote(editor, opts); 11 | 12 | // Load firestore storage 13 | firestore(editor, opts); 14 | } -------------------------------------------------------------------------------- /src/storage/indexeddb.js: -------------------------------------------------------------------------------- 1 | import { storageIDB, helpers } from '../consts'; 2 | 3 | export default (editor, opts = {}) => { 4 | let db; 5 | const sm = editor.StorageManager; 6 | const storageName = storageIDB; 7 | const objsName = opts.objectStoreName; 8 | 9 | // Functions for DB retrieving 10 | const getDb = () => db; 11 | const getAsyncDb = () => new Promise((resolve, reject) => { 12 | if (db) { 13 | resolve(db); 14 | } else { 15 | const indexedDB = window.indexedDB || window.mozIndexedDB || 16 | window.webkitIndexedDB || window.msIndexedDB; 17 | const request = indexedDB.open(opts.dbName, opts.indexeddbVersion); 18 | request.onerror = reject; 19 | request.onsuccess = () => { 20 | db = request.result; 21 | db.onerror = reject; 22 | resolve(db); 23 | }; 24 | request.onupgradeneeded = e => { 25 | const objs = request.result.createObjectStore(objsName, { keyPath: 'id' }); 26 | objs.createIndex('name', 'name', { unique: false }); 27 | }; 28 | } 29 | }); 30 | 31 | // Functions for object store retrieving 32 | const getObjectStore = () => { 33 | return db.transaction([objsName], 'readwrite').objectStore(objsName); 34 | }; 35 | const getAsyncObjectStore = async () => { 36 | if (db) { 37 | return getObjectStore(); 38 | } else { 39 | await getAsyncDb(); 40 | return getObjectStore(); 41 | } 42 | }; 43 | 44 | // Add custom storage to the editor 45 | sm.add(storageName, { 46 | ...helpers, 47 | getDb, 48 | 49 | getObjectStore, 50 | 51 | async load(keys) { 52 | const objs = await getAsyncObjectStore(); 53 | return new Promise( 54 | (resolve, reject) => { 55 | const request = objs.get(this.currentId); 56 | request.onerror = reject; 57 | request.onsuccess = () => { 58 | resolve(request.result || {}); 59 | }; 60 | } 61 | ); 62 | }, 63 | 64 | async loadAll() { 65 | const objs = await getAsyncObjectStore(); 66 | return new Promise( 67 | (resolve, reject) => { 68 | const request = objs.getAll(); 69 | request.onerror = reject; 70 | request.onsuccess = () => { 71 | resolve(request.result || []); 72 | }; 73 | } 74 | ); 75 | }, 76 | 77 | async store(data) { 78 | const objs = await getAsyncObjectStore(); 79 | return new Promise( 80 | (resolve, reject) => { 81 | const request = objs.put({ 82 | id: this.currentId, 83 | name: this.currentName, 84 | template: this.isTemplate, 85 | thumbnail: this.currentThumbnail, 86 | description: this.description, 87 | updated_at: Date.now(), 88 | ...data 89 | }); 90 | request.onerror = reject; 91 | request.onsuccess = () => { 92 | resolve(request.result); 93 | }; 94 | } 95 | ); 96 | }, 97 | 98 | async update(data) { 99 | const { id, ..._data } = data; 100 | const objs = await getAsyncObjectStore(); 101 | return new Promise( 102 | (resolve, reject) => { 103 | const request = objs.get(id); 104 | request.onerror = reject; 105 | request.onsuccess = () => { 106 | objs.put({ id, ...request.result, ..._data }); 107 | resolve(request.result); 108 | }; 109 | } 110 | ); 111 | }, 112 | 113 | async delete(index) { 114 | const objs = await getAsyncObjectStore(); 115 | return new Promise( 116 | (resolve, reject) => { 117 | const request = objs.delete(index || this.currentId); 118 | request.onerror = reject; 119 | request.onsuccess = () => { 120 | resolve(request.result); 121 | };; 122 | } 123 | ); 124 | } 125 | }); 126 | } -------------------------------------------------------------------------------- /src/storage/remote.js: -------------------------------------------------------------------------------- 1 | import { storageRemote, helpers } from '../consts'; 2 | 3 | export default (editor, opts = {}) => { 4 | const sm = editor.StorageManager; 5 | const storageName = storageRemote; 6 | const remote = sm.get('remote'); 7 | const stOpts = sm.getStorageOptions('remote'); 8 | 9 | // Add custom storage to the editor 10 | sm.add(storageName, { 11 | ...helpers, 12 | 13 | async load(keys = {}) { 14 | const { urlLoad } = stOpts; 15 | const id = urlLoad.endsWith('/') ? this.currentId : `/${this.currentId}`; 16 | const projectData = await remote.load({ 17 | ...stOpts, 18 | ...{ urlLoad: urlLoad + id }, 19 | ...keys 20 | }); 21 | return projectData; 22 | }, 23 | 24 | async loadAll(keys = {}) { 25 | return await remote.load({ ...stOpts, ...keys }); 26 | }, 27 | 28 | async store(data, keys = {}) { 29 | const { urlStore } = stOpts; 30 | const id = urlStore.endsWith('/') ? this.currentId : `/${this.currentId}`; 31 | const projectData = await remote.store({ 32 | id: this.currentId, 33 | name: this.currentName, 34 | template: this.isTemplate, 35 | thumbnail: this.currentThumbnail, 36 | description: this.description, 37 | updated_at: Date.now(), 38 | ...data 39 | }, { 40 | ...stOpts, 41 | ...{ urlStore: opts.uuidInPath ? urlStore + id : urlStore }, 42 | ...keys 43 | }); 44 | return projectData; 45 | }, 46 | 47 | async update(data, keys = {}) { 48 | const { urlStore } = stOpts; 49 | let { id } = data; 50 | id = urlStore.endsWith('/') ? id : `/${id}`; 51 | const projectData = await remote.store(data, { 52 | ...stOpts, 53 | ...{ urlStore: urlStore + id }, 54 | ...keys 55 | }); 56 | return projectData; 57 | }, 58 | 59 | async delete(index, keys = {}) { 60 | const { urlDelete } = stOpts; 61 | let id = index || this.currentId; 62 | id = urlDelete.endsWith('/') ? id : `/${id}`; 63 | const res = await remote.request(urlDelete + id, { method: 'delete', ...keys }); 64 | return res; 65 | } 66 | }); 67 | } -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* Class names prefixes */ 2 | $prefix: 'gjs-' !default; 3 | /* Main color */ 4 | $mainColor: #936a9b; 5 | 6 | :root { 7 | --border-color: rgba(0, 0, 0, 0.15); 8 | --background-color: #3c4a49; 9 | 10 | --background-box-title: #263332; 11 | } 12 | 13 | .app { 14 | margin: 20px; 15 | margin-top: 30px; 16 | animation: fadein .3s; 17 | 18 | a { 19 | text-decoration: none; 20 | color: $mainColor; 21 | } 22 | 23 | h1 { 24 | margin-bottom: 30px; 25 | } 26 | 27 | h2 { 28 | color: $mainColor; 29 | } 30 | 31 | button { 32 | cursor: pointer; 33 | background-color: #424242; 34 | font-family: inherit; 35 | color: #fff; 36 | display: inline-flex; 37 | align-items: center; 38 | justify-content: center; 39 | font-size: 15px; 40 | border: 1px solid #e9ebeb; 41 | border-bottom-color: #e1e3e3; 42 | border-radius: 4px; 43 | background-color: #fff; 44 | color: rgba(14, 30, 37, .87); 45 | box-shadow: 0 2px 4px 0 rgba(14, 30, 37, .12); 46 | transition: all .2s ease; 47 | transition-property: background-color, color, border, box-shadow; 48 | outline: 0; 49 | font-weight: 500; 50 | background: $mainColor; 51 | color: #fff; 52 | border-color: transparent; 53 | } 54 | } 55 | 56 | .title-inner { 57 | display: flex; 58 | align-items: center; 59 | 60 | button { 61 | margin-left: 20px; 62 | } 63 | } 64 | 65 | .primary-button { 66 | padding: 13px 18px; 67 | 68 | &:hover { 69 | background: #73308d; 70 | } 71 | } 72 | 73 | .flex-row { 74 | display: flex; 75 | justify-content: space-between; 76 | align-items: center; 77 | 78 | button { 79 | height: 45px; 80 | } 81 | } 82 | 83 | .tm-input { 84 | border-radius: 2px; 85 | font-size: 16px; 86 | padding: 11px 15px; 87 | min-width: 300px; 88 | display: inline-block; 89 | box-shadow: 0 0 0 2px rgba(120, 130, 152, .25); 90 | border: none; 91 | outline: none; 92 | transition: all .3s ease; 93 | margin: 20px 0; 94 | color: white; 95 | background-color: rgba(0, 0, 0, 0.2); 96 | 97 | &:active, 98 | &:focus, 99 | &:hover { 100 | box-shadow: 0 0 0 2px $mainColor; 101 | } 102 | 103 | &.sm { 104 | width: 100%; 105 | min-width: 0; 106 | font-size: 12px; 107 | margin: 10px 0; 108 | padding: 8px 10px; 109 | } 110 | } 111 | 112 | .header { 113 | cursor: pointer; 114 | font-weight: bold; 115 | font-size: 16px !important; 116 | } 117 | 118 | .item { 119 | text-align: center; 120 | } 121 | 122 | #site-list { 123 | max-height: 400px; 124 | overflow-y: auto; 125 | overflow-x: hidden; 126 | } 127 | 128 | .site-wrapper, 129 | .site-wrapper-header { 130 | display: flex; 131 | align-items: center; 132 | padding: 10px 0; 133 | padding-left: 20px; 134 | font-size: 14px; 135 | transition: .3s; 136 | } 137 | 138 | .site-wrapper.selected { 139 | border: 2px solid $mainColor; 140 | } 141 | 142 | .site-wrapper.open, 143 | .site-wrapper:nth-child(even).open { 144 | background-color: rgba(147, 106, 155, .2); 145 | } 146 | 147 | .site-wrapper-header { 148 | padding-left: 15px; 149 | border-bottom: 2px solid rgba(14, 30, 37, .2); 150 | } 151 | 152 | .site-wrapper:hover, 153 | .site-wrapper:nth-child(even):hover { 154 | background: rgba(255, 255, 255, .1); 155 | } 156 | 157 | .site-wrapper-header, 158 | .site-wrapper:nth-child(even) { 159 | background-color: rgba(0, 0, 0, .1); 160 | } 161 | 162 | .site-screenshot, 163 | .site-screenshot-header { 164 | height: 64px; 165 | margin: 0 24px 0 0; 166 | min-width: 102.4px; 167 | position: relative; 168 | overflow: hidden; 169 | } 170 | 171 | .site-screenshot-header { 172 | height: 30px; 173 | } 174 | 175 | .site-screenshot:before { 176 | background: #dadcdd; 177 | bottom: 0; 178 | content: " "; 179 | left: 0; 180 | position: absolute; 181 | right: 0; 182 | top: 0; 183 | } 184 | 185 | .site-screenshot a { 186 | display: block; 187 | position: relative; 188 | z-index: 9; 189 | height: 100%; 190 | } 191 | 192 | .site-screenshot img { 193 | position: relative; 194 | width: 100%; 195 | width: 102.4px; 196 | border: none; 197 | } 198 | 199 | .site-screenshot img[alt]:after { 200 | display: block; 201 | position: absolute; 202 | top: 0; 203 | left: 0; 204 | width: 100%; 205 | height: 100%; 206 | background-color: #dadcdd; 207 | font-weight: 300; 208 | line-height: 2; 209 | text-align: center; 210 | content: ''; 211 | } 212 | 213 | .site-info { 214 | min-width: 250px; 215 | max-width: 250px; 216 | 217 | h2 { 218 | margin: 0px; 219 | margin-bottom: 5px; 220 | } 221 | 222 | a { 223 | text-decoration: none; 224 | } 225 | } 226 | 227 | .site-meta a { 228 | font-size: 14px; 229 | color: rgba(165, 210, 230, 0.8); 230 | } 231 | 232 | .site-update-time { 233 | min-width: 150px; 234 | } 235 | 236 | .site-create-time { 237 | min-width: 130px; 238 | } 239 | 240 | .site-pages, 241 | .site-size { 242 | min-width: 80px; 243 | } 244 | 245 | .site-actions { 246 | min-width: 200px; 247 | 248 | i { 249 | color: $mainColor; 250 | background-color: rgba(0, 0, 0, 0.1); 251 | border: 1px solid $mainColor; 252 | border-radius: 2px; 253 | padding: 5px; 254 | cursor: pointer; 255 | 256 | &:hover { 257 | background-color: $mainColor; 258 | color: white; 259 | } 260 | } 261 | } 262 | 263 | .#{$prefix}templates-overlay { 264 | position: absolute; 265 | pointer-events: none; 266 | top: 0; 267 | width: 100%; 268 | height: 100%; 269 | background-color: rgba(255, 255, 255, 0.05); 270 | } 271 | 272 | .#{$prefix}mdl-dialog-tml { 273 | max-width: 1000px; 274 | margin-top: 45px; 275 | } 276 | 277 | .#{$prefix}mdl-dialog-md { 278 | max-width: 400px; 279 | margin-top: 45px; 280 | } 281 | 282 | .#{$prefix}tip-about { 283 | padding: 10px; 284 | font-size: .9rem; 285 | border: 1px solid rgba(0, 0, 0, 0.2); 286 | border-left: 3px solid $mainColor; 287 | background-color: rgba(0, 0, 0, 0.1); 288 | margin-bottom: 10px; 289 | } 290 | 291 | .#{$prefix}tab { 292 | overflow: hidden; 293 | border-bottom: 1px solid rgba(0, 0, 0, 0.2); 294 | margin-bottom: 10px; 295 | 296 | button { 297 | background-color: inherit; 298 | color: inherit; 299 | float: left; 300 | border: 1px solid rgba(0, 0, 0, 0.1); 301 | outline: none; 302 | cursor: pointer; 303 | padding: 14px 16px; 304 | transition: .3s; 305 | 306 | &:hover { 307 | background-color: rgba(0, 0, 0, 0.1); 308 | } 309 | 310 | &.active { 311 | background-color: rgba(0, 0, 0, 0.2); 312 | } 313 | } 314 | } 315 | 316 | .pages-wrp, 317 | .pages { 318 | display: flex; 319 | flex-direction: column; 320 | } 321 | 322 | .pages-wrp { 323 | padding: 5px; 324 | } 325 | 326 | .pages-title { 327 | padding: 5px; 328 | margin: 0; 329 | border-bottom: 1px solid rgba(0, 0, 0, .1); 330 | } 331 | 332 | .add-page { 333 | background: $mainColor; 334 | color: white; 335 | font-size: 12px; 336 | padding: 8px 5px; 337 | border-radius: 2px; 338 | cursor: pointer; 339 | white-space: nowrap; 340 | margin-bottom: 10px; 341 | } 342 | 343 | .page { 344 | font-size: 12px; 345 | text-align: left; 346 | padding: 5px; 347 | margin-bottom: 5px; 348 | border-radius: 2px; 349 | border: 1px solid rgba(0, 0, 0, .2); 350 | box-shadow: 0 1px 0 0 rgb(0, 0, 0, .15); 351 | transition: all .2s ease 0s; 352 | transition-property: box-shadow, color; 353 | cursor: pointer; 354 | 355 | &:hover { 356 | color: $mainColor; 357 | box-shadow: 0 3px 4px 0 rgb(0, 0, 0, .15); 358 | } 359 | 360 | &.selected { 361 | color: $mainColor; 362 | border: 1px solid $mainColor; 363 | background-color: rgba(0, 0, 0, .1); 364 | } 365 | } 366 | 367 | .page-edit, 368 | .page-close { 369 | opacity: .5; 370 | float: right; 371 | background-color: rgba(0, 0, 0, .1); 372 | border: 1px solid rgba(0, 0, 0, .2); 373 | height: 17px; 374 | width: 17px; 375 | text-align: center; 376 | border-radius: 3px; 377 | 378 | &:hover { 379 | opacity: 1; 380 | } 381 | } 382 | 383 | .page-edit { 384 | margin-right: 5px; 385 | } 386 | 387 | .group { 388 | margin-bottom: 15px; 389 | 390 | input { 391 | padding: 0; 392 | height: initial; 393 | width: initial; 394 | margin-bottom: 0; 395 | display: none; 396 | cursor: pointer; 397 | 398 | &:checked+label:after { 399 | content: ''; 400 | display: block; 401 | position: absolute; 402 | top: 2px; 403 | left: 9px; 404 | width: 6px; 405 | height: 14px; 406 | border: solid $mainColor; 407 | border-width: 0 2px 2px 0; 408 | transform: rotate(45deg); 409 | } 410 | } 411 | 412 | label { 413 | position: relative; 414 | cursor: pointer; 415 | 416 | &:before { 417 | content: ''; 418 | -webkit-appearance: none; 419 | background-color: transparent; 420 | border: 2px solid $mainColor; 421 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), inset 0px -15px 10px -12px rgba(0, 0, 0, 0.05); 422 | padding: 10px; 423 | display: inline-block; 424 | position: relative; 425 | vertical-align: middle; 426 | cursor: pointer; 427 | margin-right: 5px; 428 | } 429 | } 430 | } 431 | 432 | @keyframes fadein { 433 | 0% { 434 | opacity: 0; 435 | } 436 | 437 | 100% { 438 | opacity: 1; 439 | } 440 | } -------------------------------------------------------------------------------- /src/utils/objsize.js: -------------------------------------------------------------------------------- 1 | export function objSizeMegaBytes(obj) { 2 | return objSizeBytes(obj) / (1024 * 1024); 3 | } 4 | 5 | export default function objSizeKiloBytes(obj) { 6 | return objSizeBytes(obj) / 1024; 7 | } 8 | 9 | export function objSizeBytes(obj) { 10 | return new TextEncoder().encode(JSON.stringify(obj)).length; 11 | } -------------------------------------------------------------------------------- /src/utils/sort.js: -------------------------------------------------------------------------------- 1 | // https://github.com/netlify-labs/oauth-example/blob/master/src/utils/sort.js 2 | // License MIT 3 | import objSize from './objsize'; 4 | 5 | export function matchText(search, text) { 6 | if (!text || !search) { 7 | return false 8 | } 9 | return text.toLowerCase().indexOf(search.toLowerCase()) > -1 10 | } 11 | 12 | export function sortByDate(dateType, order) { 13 | return function (a, b) { 14 | const timeA = new Date(a[dateType]).getTime() 15 | const timeB = new Date(b[dateType]).getTime() 16 | if (order === 'asc') { 17 | return timeA - timeB 18 | } 19 | // default 'desc' descending order 20 | return timeB - timeA 21 | } 22 | } 23 | 24 | export function sortByName(key, order) { 25 | return function (a, b) { 26 | if (order === 'asc') { 27 | if (a[key] < b[key]) return -1 28 | if (a[key] > b[key]) return 1 29 | } 30 | if (a[key] > b[key]) return -1 31 | if (a[key] < b[key]) return 1 32 | return 0 33 | } 34 | } 35 | 36 | export function sortByPages(key, order) { 37 | return function (a, b) { 38 | const pagesA = JSON.parse(a[key]); 39 | const pagesB = JSON.parse(b[key]); 40 | if (order === 'desc') { 41 | if (pagesA.length < pagesB.length) return -1 42 | if (pagesA.length > pagesB.length) return 1 43 | } 44 | if (pagesA.length > pagesB.length) return -1 45 | if (pagesA.length < pagesB.length) return 1 46 | return 0 47 | } 48 | } 49 | 50 | export function sortBySize(order) { 51 | return function (a, b) { 52 | const sizeA = objSize(a); 53 | const sizeB = objSize(b); 54 | if (order === 'asc') { 55 | if (sizeA < sizeB) return -1 56 | if (sizeA > sizeB) return 1 57 | } 58 | if (sizeA > sizeB) return -1 59 | if (sizeA < sizeB) return 1 60 | return 0 61 | } 62 | } -------------------------------------------------------------------------------- /src/utils/timeago.js: -------------------------------------------------------------------------------- 1 | export default function timeago(date, short) { 2 | const obj = { 3 | second: 1000, 4 | minute: 60 * 1000, 5 | hour: 60 * 1000 * 60, 6 | day: 24 * 60 * 1000 * 60, 7 | week: 7 * 24 * 60 * 1000 * 60, 8 | month: 30 * 24 * 60 * 1000 * 60, 9 | year: 365 * 24 * 60 * 1000 * 60, 10 | } 11 | 12 | const { round } = Math, 13 | dir = ' ago', 14 | pl = function (v, n) { 15 | return (short === undefined) ? `${n} ${v + (n > 1 ? 's' : '') + dir}` : n + v.substring(0, 1) 16 | }, 17 | ts = Date.now() - new Date(date).getTime(); 18 | let ii; 19 | 20 | if (ts < 0) { 21 | ts *= -1; 22 | dir = ' from now'; 23 | } 24 | 25 | for (var i in obj) { 26 | if (round(ts) < obj[i]) return pl(ii || 'm', round(ts / (obj[ii] || 1))) 27 | ii = i; 28 | } 29 | return pl(i, round(ts / obj[i])); 30 | } -------------------------------------------------------------------------------- /src/utils/ui.js: -------------------------------------------------------------------------------- 1 | export default class UI { 2 | constructor(editor, opts = {}) { 3 | this.editor = editor; 4 | this.$ = editor.$; 5 | this.pfx = editor.getConfig('stylePrefix'); 6 | this.opts = opts; 7 | this.setState = this.setState.bind(this); 8 | this.setStateSilent = this.setStateSilent.bind(this); 9 | this.onRender = this.onRender.bind(this); 10 | this.handleTabs = this.handleTabs.bind(this); 11 | } 12 | 13 | setState(state) { 14 | this.state = { ...this.state, ...state }; 15 | this.update(); 16 | } 17 | 18 | setStateSilent(state) { 19 | this.state = { ...this.state, ...state }; 20 | } 21 | 22 | get sm() { 23 | return this.editor.Storage; 24 | } 25 | 26 | get cs() { 27 | return this.editor.Storage.getCurrentStorage(); 28 | } 29 | 30 | get pm() { 31 | return this.editor.Pages; 32 | } 33 | 34 | onRender() { } 35 | 36 | handleTabs() { } 37 | } --------------------------------------------------------------------------------