/** * The Vue application instance. * https://vuejs.org/guide/essentials/application.html * This is the root component */ const app = Vue.createApp({ /** * to prevent conflict with jekyll we change the * delimiters from {{ }} to [[ ]] */ delimiters: ['[[', ']]'], /** * The data object of the root component. * These variables are available in the html template. */ data() { return { // The rule object rule: global.rule || { ruleID: "", timestamp: "", icon: "", name: "", lineage: "", summary: "", creator: { name: "", url: "", }, modules: [] }, legacy: false, rID: false, loading: false, publishing: false, view: (global.rule) ? true : false, preview: (global.rule) ? true : false, template: (global.rule) ? true : false, steinAPI: 'https://api.steinhq.com/v1/storages/5e8b937ab88d3d04ae0816a5', // TODO keep an array of past states for undo/redo history: [], // the data of the current module in the editor editor: { source: null, previousState: null, module: null, }, // the module that is currently being dragged holding: false, // object of icons for easy access icons: { plus: '/assets/tabler_icons/circle-plus.svg', info: '/assets/tabler_icons/info-circle.svg', chevron: '/assets/tabler_icons/chevrons-down.svg', blank: '/assets/tabler_icons/circle-dotted.svg', view: '/assets/tabler_icons/eye.svg', edit: '/assets/tabler_icons/tool.svg', plus: '/assets/tabler_icons/plus.svg', minus: '/assets/tabler_icons/minus.svg', publish: '/assets/tabler_icons/cloud-upload.svg', download: '/assets/tabler_icons/download.svg', export: '/assets/tabler_icons/file-download.svg', upload: '/assets/tabler_icons/file-upload.svg', fork: '/assets/tabler_icons/git-fork.svg', }, // icons available in the editor moduleIcons: { culture: '/assets/tabler_icons/palette.svg', decision: '/assets/tabler_icons/thumb-up.svg', process: '/assets/tabler_icons/rotate.svg', structure: '/assets/tabler_icons/building.svg', relationship: '/assets/tabler_icons/heart.svg', economic: '/assets/tabler_icons/coin.svg', legal: '/assets/tabler_icons/license.svg', map: '/assets/tabler_icons/map.svg', communications: '/assets/tabler_icons/microphone.svg', }, // icons available for rules ruleIcons: { atom: '/assets/tabler_icons/atom.svg', bandage: '/assets/tabler_icons/bandage.svg', book: '/assets/tabler_icons/book.svg', box: '/assets/tabler_icons/box.svg', church: '/assets/tabler_icons/building-church.svg', store: '/assets/tabler_icons/building-store.svg', brush: '/assets/tabler_icons/brush.svg', car: '/assets/tabler_icons/car.svg', clock: '/assets/tabler_icons/clock.svg', cloud: '/assets/tabler_icons/cloud.svg', compass: '/assets/tabler_icons/compass.svg', game: '/assets/tabler_icons/device-gamepad.svg', flask: '/assets/tabler_icons/flask.svg', location: '/assets/tabler_icons/location.svg', moon: '/assets/tabler_icons/moon.svg', settings: '/assets/tabler_icons/settings.svg', shield: '/assets/tabler_icons/shield.svg', star: '/assets/tabler_icons/star.svg', tool: '/assets/tabler_icons/tool.svg', world: '/assets/tabler_icons/world.svg', }, // the template for modules moduleTemplate: { moduleID: "", name: "", icon: "", summary: "", type: "", modules: [] }, // tracks the current module library tab that is open moduleLibrary: 'culture', moduleTypes: { // custom: { // question: 'Modules that you\'ve created.', // icon: '/assets/tabler_icons/circle-plus.svg', // open: true // }, culture: { question: 'What are the core missions, values, and norms?', icon: '/assets/tabler_icons/palette.svg', open: true }, decision: { question: 'Who can make decisions and how?', icon: '/assets/tabler_icons/thumb-up.svg', open: false }, process: { question: 'How are policies implemented, and how do they evolve?', icon: '/assets/tabler_icons/rotate.svg ', open: false }, structure: { question: 'What kinds of roles and internal entities are there?', icon: '/assets/tabler_icons/building.svg', open: false } }, // array of modules that have been created by the user // TODO: implement custom modules customModules: [], // library of existing modules modules: global.modules, exports: { markdown: null, json: null, } } }, /** * Vue provide passes data to other components. * https://vuejs.org/guide/components/provide-inject.html#provide-inject */ provide() { return { editor: this.editor, icons: this.icons, } }, created() { this.addToEditor(this.newModule()); var urlParams = new URLSearchParams(window.location.search); if (urlParams.has('r')) { this.rID = urlParams.get('r'); this.preview = true; this.view = true; this.fetchRule(this.rID); } }, computed: { /** * Exports the current rule into a normalized format * Cleans up all submodules so they are ready for export * @returns {Object} a Rule object */ ruleExport() { //TODO: test if icon is an absolute url and only add the global if it is not /** * Takes a module and recursively cleans it and all submodules up * @param {Object} module a module object * @returns */ function cleanModules(module) { const newModule = { moduleID: module.moduleID, name: module.name, icon: (module.icon && !module.icon.includes('http')) ? global.url + module.icon : module.icon, summary: module.summary, type: module.type, modules: (module.modules) ? module.modules.map(cleanModules) : [], } return newModule; } return { ruleID: (this.rule.ruleID) ? this.rule.ruleID : this.slugify(this.rule.name), timestamp: this.timesString(), icon: (this.rule.icon && !this.rule.icon.includes('http')) ? global.url + this.rule.icon : this.rule.icon, name: this.rule.name, lineage: this.rule.lineage, summary: this.rule.summary, creator: { name: this.rule.creator.name, url: this.rule.creator.url, }, modules: this.rule.modules.map(cleanModules) } }, /** * @returns {String} the current rule as a JSON string */ json() { return JSON.stringify(this.ruleExport, null, 2); }, /** * Creates an array of all moduleIDs in use * @returns {Array} an array of all module's (in the library and custom modules) moduleID */ listModuleIds() { const modules = [...this.rule.modules, ...this.customModules]; return modules.map(module => module.moduleID) }, /** * @returns {Object} the current module in the editor */ moduleInEditor() { return this.moduleEditor[0] }, /** * Tests if the current module in the editor has been modified * against the editor.previousState object * @returns {Boolean} true if the module in the editor has been modified */ //TODO: find a more accurate solution than just turning the object into a string editorHasEdits() { return this.editor.module && Object.entries(this.editor.module).toString() !== Object.entries(this.editor.previousState).toString(); }, }, methods: { // module methods =========================================================== /** * @returns {Object} a new module object from the moduleTemplate */ newModule() { return { ...this.moduleTemplate } }, /** * spreads a source module into a new module from the moduleTemplate * @param {Object} sourceModule the module to copy * @param {Boolean} includeSubmodules whether to copy submodules or not * @returns */ cloneModule(sourceModule,includeSubmodules) { let output = { ...this.moduleTemplate, ...sourceModule, //TODO: implement lineage pattern, same as the rule does source: sourceModule, // keep track of where this module came from }; if (!includeSubmodules) output.modules = []; // delete unnecessary properties delete output.content; delete output.readonly; // TODO: give module a unique id // if (output.moduleID) output.moduleID = this.getUniqueId(output.moduleID); return output; }, /** * Handles the click event to copy a module * @param {Event} ev the click event */ handleClickCopyModule(ev) { const clickTarget = this.getClosestModule(ev.target); if (!clickTarget) return; this.copyModule(clickTarget.module); }, /** * Handles the click event to edit a module * @param {Event} ev the click event */ handleClickEditModule(ev) { const clickTarget = this.getClosestModule(ev.target); if (!clickTarget) return; this.editModule(clickTarget.module); }, /** * Copies a module to the editor * @param {Object} module the module to copy */ copyModule(module) { this.copyToEditor(module); }, /** * moves a module to the editor * @param {Object} module the module to edit */ editModule(module) { this.addToEditor(module); }, /** * add a module to another module (or to the rule by default) as a submodule * @param {Object} module to add * @param {Object} target a module or the rule Object where the module should be added as a submodule */ addModule(module,target = this.rule) { target.modules.push(module); }, /** * remove a module from another module (or from the rule) * recursively moves through all submodules in the target * removes ONLY the first instance of the module * @param {Object} module the module to remove from target * @param {Object} target the module or rule to remove the module from (defaults to rule) */ removeModule(module, target = this.rule) { if (! this.moduleContains(module, target)) return; // if the module is not in the target, do nothing // target.modules.forEach((m, idx) => { if (m === module) { target.modules.splice(idx, 1); return; } else { this.removeModule(module, m); } }); }, /** * Deletes custom module from the customModules array and clears the editor * @param {Object} module the module to be deleted */ deleteModule(module) { this.removeCustomModule(module); // TODO: only clear the editor if the module is present in the editor this.clearEditor(); }, /** * Handles the start drag event for a module * @param {Event} ev the drag event */ startDragModule(ev) { const targetModule = this.getClosestModule(ev.target); if (!targetModule) return; const module = targetModule.module; ev.dataTransfer.setDragImage(targetModule, ev.offsetX, ev.offsetY); this.holding = {module}; }, /** * Handles the start drag event for a module * when the module is being rearranged within the rule * @param {Event} ev the drag event */ rearrangeModule(ev) { const targetModule = this.getClosestModule(ev.target); if (!targetModule) return; const source = this.getClosestModule(targetModule.parentNode).module; const module = targetModule.module; ev.dataTransfer.setDragImage(targetModule, ev.offsetX, ev.offsetY); this.holding = { module, source, }; }, /** * Handles the dragend event for a module */ endDragModule() { this.holding = false; }, /** * Recursively searches modules and their submodules for a module * @param {Object} needle the module to search for * @param {Object} haystack the module to search in (defaults to rule) * @returns {Boolean} true if the module is in the haystack */ // TODO: return the module location in the haystack (Maybe?) moduleContains(needle, haystack = this.rule) { if (! haystack.modules ) return false; // does the haystack even have modules? if (haystack.modules.includes(needle)) return true; // is the needle in the haystack? return haystack.modules.some(submodule => this.moduleContains(needle, submodule)); // is the needle in any of the submodules? }, // rule methods =========================================================== /** * Handles the drop event for a module * adds the module to the closest submodule or the rule depending on what it is dropped onto * then adds the module to the editor * @param {Event} ev the drop event */ dropOnRule(ev) { //TODO browser drag objects that hover over drop zone are showing a 'add' icon const landingNode = this.getClosestModule(ev.target); if (!this.holding.module || !landingNode) return; // if there is no module to drop or no landing node, do nothing const landingModule = landingNode.module; // module is set with the v-bind prop binding const holdingModule = this.holding.module; if (holdingModule === landingModule) return; // if the module is the same, do nothing // if the module being dropped is readyonly clone it, otherwise use the original const readonly = holdingModule.readonly; const module = (readonly) ? this.cloneModule(holdingModule) : holdingModule; if (this.holding.source) { // if the module has a source, remove it from that source this.removeModule(holdingModule, this.holding.source); } this.addModule(module, landingModule); this.editModule(module); this.endDragModule(); }, fetchRule(id) { this.loading = true; // handle legacy links // TODO: handle this at a global level let redirect = { 'benevolent_dictator': 'benevolent-dictator', circles: 'circles', consensus: 'consensus', 'do-ocracy': 'do-ocracy', 'elected_board': 'elected-board', jury: 'jury', petition: 'petition', 'self-appointed_board': 'self-appointed-board', } // if the rule is a legacy link, redirect if (redirect[id]) { location.href = `/templates/${redirect[id]}`; return; } const store = new SteinStore( this.steinAPI ); (async () => { var rule = []; // read values from all sheets await store.read('rules', { search: { ruleID: id } }).then(data => { // test if there's anything in data if (data.length > 0) { rule = data[0]; } console.log(rule); }); // no rule found, exit // TODO: inform the user that the rule was not found if (!rule) { this.loading = false; return; } // if this is a legacy (pre-v3) set it as such if (rule.version < 3) { this.loading = false; this.legacy = true; this.rule = rule; Vue.nextTick(() => { if (rule.version == 2) displayBuilderHTML(); }); return; } this.rule = { ruleID: rule.ruleID, timestamp: rule.timestamp, icon: rule.icon, name: rule.name, lineage: rule.lineage, summary: rule.summary, creator: { name: rule.creatorName, url: rule.creatorUrl, }, modules: (rule.modules) ? JSON.parse(rule.modules) : [] } /** Add name to for v3+ rules */ document.title = rule.name + " / CommunityRule" this.loading = false; })(); }, // editor methods ========================================================= /** * Adds a module to the editor * @param {Object} module the module to add to the editor */ addToEditor(module) { this.preventEditorLoss(); this.setEditorSource(module); this.setEditorPreviousState(module); this.editor.module = module; }, /** * Copies a module to the editor * @param {Object} module the module to copy to the editor */ copyToEditor(module) { const moduleCopy = this.cloneModule(module); this.preventEditorLoss(); this.setEditorSource(module); this.setEditorPreviousState(moduleCopy); this.editor.module = moduleCopy; }, /** * Takes a module and clones it into the editor.previousState * @param {Object} module the module to add to the previous state */ setEditorPreviousState(module) { this.editor.previousState = { ...module }; }, /** * Sets the editor.source to the module * @param {Object} module the module to set the editor source to */ setEditorSource(module) { this.editor.source = module; }, /** * Checks if the editor has edits and that the current module in the editor is not present in the rule * If the module in the editor would be lost, confirm with the user * then adds the module to the customModules array */ preventEditorLoss() { // if the editor has changes and the module isn't in the rule, save it to the custom modules if (this.editorHasEdits && !this.moduleContains(this.editor.module)) { this.confirm('You have unsaved changes. Are you sure you want to discard them?') this.addCustomModule(this.editor.module); } }, /** * Handles the click event for adding a module from the editor to the rule */ clickAddModule() { const module = this.editor.module; this.addModule(module); this.addToEditor(module); }, /** * Handles the click event for removing a module in the editor from the rule */ clickRemoveModule() { const module = this.editor.module; this.removeModule(module); }, /** * Clears the editor */ clearEditor() { this.preventEditorLoss(); this.editor.module = null; this.editor.previousState = null; }, /** * Saves the module in the editor to customModules array */ saveEditor() { this.addCustomModule(this.editor.module); this.setEditorPreviousState(this.editor.module); }, // custom module methods ================================================== /** * Adds a module to the customModules array * @param {Object} module the module to add to the customModules array */ addCustomModule(module) { // if module is not in customModules, add it if (!this.customModules.includes(module)) { this.customModules.unshift(module); } }, /** * Creates a new module, set's a default name, and adds it to the editor */ newCustomModule() { const module = this.newModule(); module.name = 'New Module'; this.addToEditor(module); }, /** * Removes a module from the customModules array * @param {Object} module the module to remove from the customModules array */ removeCustomModule(module) { this.confirm("are you sure you want to remove this custom module?"); const index = this.customModules.indexOf(module); this.customModules.splice(index, 1); }, /** * Handles confirmation messages for users * @param {String} msg the message to display in the confirm dialog */ // TODO: add a confirm dialog and return boolean based on user input confirm(msg) { console.log(msg); }, // export and download methods ============================================= /** * Handles click event for publishing the rule */ handleClickPublish() { // Confirm user knows what they're getting into if (!confirm("Publish to the public Library?")) return; if ( !this.rule.name ) { alert("Please enter a name for this rule."); return; } if ( this.rule.modules.length < 1 ) { alert("Please add at least one module to this rule."); return; } this.publishing = true; const rule = this.ruleExport; const ruleID = new Date().getTime(); // TODO: allow for custom named IDs, check for uniqueness // add to database const store = new SteinStore( this.steinAPI ); store.append('rules', [{ ruleID: ruleID, timestamp: rule.timestamp, icon: rule.icon, name: rule.name, lineage: rule.lineage, summary: rule.summary, modules: this.jsonify(rule.modules), creatorName: rule.creator.name, creatorUrl: rule.creator.url, version: 3 }]).then(data => { this.publishing = false; window.open("/create/?r=" + ruleID, "_self", false); }); }, /** * Handles the click event for downloading the rule as a Markdown file * Creates a YAML string of the rule * Then adds it to the bottom of a markdown file * created from the #rule-export html element */ handleClickDownload() { const yaml = jsyaml.dump(this.ruleExport); const turndown = new TurndownService(); const html = document.getElementById('rule-export'); if (!html) return; const markdown = turndown.turndown(html); const output = markdown + '\n\n----\n```yaml\n' + yaml + '\n```'; this.saveFile(`${this.ruleExport.ruleID}.md`, output, 'text/markdown'); }, /** * Handles the click event for downloading the rule as a JSON file */ handleClickExport() { const output = this.json; this.saveFile(`${this.ruleExport.ruleID}.json`, output, 'application/json'); }, /** * IE10+ Firefox, and Chrome method for saving a file * https://stackoverflow.com/a/33542499 * @param {String} filename name of the file to save * @param {String} data data to save into a file * @param {String} type MIME type of the file */ saveFile(filename, data, type) { const blob = new Blob([data], { type: type }); if (window.navigator.msSaveOrOpenBlob) { window.navigator.msSaveBlob(blob, filename); } else { const elem = window.document.createElement('a'); elem.href = window.URL.createObjectURL(blob); elem.download = filename; document.body.appendChild(elem); elem.click(); document.body.removeChild(elem); } }, /** * Handles the click event for importing a rule from a JSON file */ handleClickImport() { }, // utility methods =========================================================== /** * Takes an html element and finds the closest node (inclusive) that has a data-module-id attribute * @param {Node} el the html element to check * @returns {Node} the closest node with a data-module-id attribute */ getClosestModule(el) { const parent = el.closest('[data-module-id]'); if (!parent) return false; if (!parent.module) return false; return parent; }, /** * Handles the click event for activating the rule preview */ clickPreview() { if(this.template) this.rule.icon = ''; // TODO: find a less hacky way to reset template icons this.view = false; this.preview = !this.preview; }, /** * Filters module library based on the search term * @param {String} type the name of the type to filter * @returns {Array} the filtered module library */ getModulesByType(type) { return this.modules.filter(module => module.type === type) }, /** * Slugifies a string * https://gist.github.com/codeguy/6684588 (one of the comments) * @param {String} string the string to slugify * @returns */ slugify(string) { const separator = '-'; return string .toString() .normalize('NFD') // split an accented letter in the base letter and the accent .replace(/[\u0300-\u036f]/g, '') // remove all previously split accents .toLowerCase() .replace(/[^a-z0-9 -]/g, '') // remove all chars not letters, numbers and spaces (to be replaced) .trim() .replace(/\s+/g, separator); }, /** * Creates a human readable timestamp string * @param {String} date optional date to format * @returns {String} human readable date '2022.4.12 14:44:56 UTC' */ timesString(date) { let now = new Date(date); if (isNaN(now)) now = new Date(); return now.getUTCFullYear() + '.' + (now.getUTCMonth() + 1) + '.' + now.getUTCDate() + ' ' + now.getUTCHours() + ":" + now.getUTCMinutes() + ":" + now.getUTCSeconds() + ' UTC'; }, /** * stringify an Object * @param {Object} obj * @returns JSON string */ jsonify(obj) { return JSON.stringify(obj, null, 2); }, /** * Takes a moduleID and checks if that moduleID is in use * if so, returns the moduleID with a number appended to it * @param {String} test the moduleID to test * @returns {String} the moduleID, or if in use, with a number appended to it */ getUniqueId(test) { let id = test; let i = 0; while (this.listModuleIds.includes(id)) { i++; id = `${test}-${i}`; } return id }, }, }); /** * The Module Vue Component */ app.component('module', { delimiters: ['[[', ']]'], inject: ['editor'], props: { module: { type: Object, required: true, }, inEditor: { type: Boolean, default: false, }, hideSubmodules: { type: Boolean, default: false, } }, data() { return { defaultIcon: '/assets/tabler_icons/circle-dotted.svg', mouseOver: false, dragOver: false } }, computed: { icon() { return this.module.icon ? this.module.icon : this.defaultIcon; }, moduleClass() { return { 'in-editor': this.editor.source == this.module, 'mouse-over': this.mouseOver, // TODO: when dragging over the icon the drag-over class disappears 'drag-over': this.dragOver } } }, template: ` <div class="module" :class="moduleClass" .module="module" @mouseover.stop="this.mouseOver = true" @mouseout="this.mouseOver = false" @dragenter.self="this.dragOver = true" @dragleave.self="this.dragOver = false" @drop.self="this.dragOver = false" :data-module-id="module.moduleID" > <div class="module__icon-wrapper"> <span class="module__grain"><img src="/assets/tabler_icons/grain.svg"></span> <span class="module__icon"><img :src="icon"></span> </div> [[module.name]] <div class="submodules" v-if="!hideSubmodules && module.modules && module.modules.length"> <module v-for="submodule in module.modules" :module="submodule" draggable="true"></module> </div> </div> ` }) /** * A non-interactive Module Vue Component * Used for displaying the module */ app.component('moduleDisplay', { delimiters: ['[[', ']]'], props: { module: { type: Object, required: true, } }, data() { return { defaultIcon: '/assets/tabler_icons/circle-dotted.svg' } }, computed: { icon() { return this.module.icon ? this.module.icon : this.defaultIcon; } }, template: ` <div class="module" .module="module" > <div class="module__icon-wrapper"> <span class="module__icon"><img :src="icon"></span> </div> [[module.name]] <div class="submodules" v-if="module.modules && module.modules.length"> <module-display v-for="submodule in module.modules" :module="submodule"></module-display> </div> </div> ` }) /** * The Module list Vue Component */ app.component('moduleList', { delimiters: ['[[', ']]'], props: { module: { type: Object, required: true, }, hideIcon: { type: Boolean, default: false, } }, data() { return { defaultIcon: '/assets/tabler_icons/circle-dotted.svg' } }, computed: { icon() { return this.module.icon ? this.module.icon : this.defaultIcon; } }, template: ` <li class="module-list-item"> <span class="module__icon" v-if="!hideIcon"><img :src="icon"> </span><strong>[[module.name]]</strong>: [[module.summary]] <ul class="submodules" v-if="module.modules && module.modules.length"> <module-list v-for="submodule in module.modules" :module="submodule" :hide-icon="hideIcon"></module-list> </ul> </li> ` }) /** * A simple button Vue Component */ app.component('vueButton', { delimiters: ['[[', ']]'], props: { icon: { type: String, required: false, default: false }, loading: { type: Boolean, required: false, default: false } }, computed: { classList() { return { 'has-icon': this.icon, 'is-loading': this.loading }; }, activeIcon() { return this.loading ? '/assets/tabler_icons/refresh.svg' : this.icon; } }, template: ` <button class="btn" :class="classList"><img :src="activeIcon" v-if="icon"> <slot>Click Here</slot></button> ` }) /** * A icon Vue Component */ app.component('icon', { delimiters: ['[[', ']]'], props: { icon: { type: String, required: true, default: '/assets/tabler_icons/circle-dotted.svg' } }, template: ` <span class="icon"><img :src="icon"></span> ` }) /** * Mounts the app to the DOM element with the id 'app' */ vm = app.mount('#app') /** * Legacy functions for displaying old rules */ // Turns RuleBuilder contents into an output-ready nested array // Returns empty array if no modules function builderArray() { var modules = document.getElementById("module-input").children; // takes an array of children // returns an array with all modules in the array, recursively nested function iterateArray(childs) { var moduleArray = []; if (childs.length > 0) { for (var i = 0; i < childs.length; i++) { module = childs[i]; if (module.classList[0] == "module") { var moduleName = module.children.item("module-name"); var moduleData = moduleName.title; var moduleChilds = module.children; moduleArray.push( [stripHTML(moduleName.innerHTML), stripHTML(moduleData), iterateArray(moduleChilds)]); } } } return moduleArray; } // end function return iterateArray(modules); } // returns HTML version of Builder content function displayBuilderHTML() { var output = ""; var mainArray = builderArray(); function arrayHTML(thisArray) { var thisOutput = ""; if (thisArray.length > 0) { thisOutput += '<ul>\n'; for (var i = 0; i < thisArray.length; i++) { var item = thisArray[i]; thisOutput += '<li><strong>' + item[0] + '</strong> '; thisOutput += item[1] + '</li>\n'; if (item[2].length > 0) { thisOutput += arrayHTML(item[2]); } } thisOutput += '</ul>\n'; } return thisOutput } if (mainArray.length > 0) { output += '<div class="builder-list">\n'; output += arrayHTML(mainArray) + '\n</div>\n'; } document.getElementById("builder-field").innerHTML = output; } // Removes all HTML content, replacing line break tags with newlines function stripHTML(input) { input = input.replace(/<br ?\/?>/ig, "\n").replace(/(<([^>]+)>)/ig, ''); return input; }