designer 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitattributes +2 -0
- data/.gitignore +4 -0
- data/.travis.yml +17 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +142 -0
- data/LICENSE +21 -0
- data/README.md +102 -0
- data/Rakefile +27 -0
- data/TODO.md +6 -0
- data/app/assets/stylesheets/designer.scss +189 -0
- data/app/assets/stylesheets/designer/_texy.scss +311 -0
- data/app/controllers/designer/application_controller.rb +33 -0
- data/app/controllers/designer/editor_controller.rb +50 -0
- data/app/controllers/designer/images_controller.rb +66 -0
- data/app/helpers/designer/designer_helper.rb +95 -0
- data/app/javascript/designer/DefaultInput.vue +82 -0
- data/app/javascript/designer/components/DefaultForm.vue +65 -0
- data/app/javascript/designer/components/DefaultInput.vue +82 -0
- data/app/javascript/designer/components/MediaGallery.vue +564 -0
- data/app/javascript/designer/components/SortableInputArray.vue +74 -0
- data/app/javascript/designer/editor.js +65 -0
- data/app/javascript/designer/helpers.js +73 -0
- data/app/javascript/designer/index.js +136 -0
- data/app/javascript/packs/designer.js +2 -0
- data/app/views/designer/application/notifications.js.erb +13 -0
- data/app/views/designer/editor/show.html.slim +88 -0
- data/app/views/designer/elements/_image.html.slim +11 -0
- data/app/views/designer/elements/_quote.html.slim +7 -0
- data/app/views/designer/elements/_separator.html.slim +1 -0
- data/app/views/designer/elements/_text.html.slim +5 -0
- data/app/views/layouts/designer/application.html.slim +9 -0
- data/bin/test +6 -0
- data/bin/webpack +19 -0
- data/bin/webpack-dev-server +19 -0
- data/config/routes.rb +6 -0
- data/designer.gemspec +29 -0
- data/lib/designer.rb +10 -0
- data/lib/designer/attribute.rb +15 -0
- data/lib/designer/configuration.rb +6 -0
- data/lib/designer/engine.rb +35 -0
- data/lib/designer/version.rb +5 -0
- data/lib/tasks/designer.rake +20 -0
- data/lib/templates/dev_installer.rb +13 -0
- data/lib/templates/installer.rb +11 -0
- data/package.json +33 -0
- data/yarn.lock +288 -0
- metadata +160 -0
@@ -0,0 +1,74 @@
|
|
1
|
+
<template>
|
2
|
+
<Draggable v-model="object" :options="{handle:'.drag-handle-child'}" @update="onUpdate">
|
3
|
+
<div class="section-heading d-flex align-items-center">
|
4
|
+
<div class="title flex-fill">{{ spec.label || spec.name }}</div>
|
5
|
+
<a class="btn btn-sm btn-success" @click.prevent="object.push({})" href="#"><i class="fa fa-plus"></i> Add</a>
|
6
|
+
</div>
|
7
|
+
<div class="card child" v-for="(data, index) in object">
|
8
|
+
<div class="card-header drag-handle-child">
|
9
|
+
<a class="btn btn-sm float-right" @click.prevent="object.splice(index, 1)" href="#"><i class="fa fa-trash"></i></a>
|
10
|
+
<a class="title" data-toggle="collapse" v-bind:data-target="'#collapse-' + index" href="#">
|
11
|
+
<span v-if="data.title">{{ data.title }}</span>
|
12
|
+
<span v-else-if="data.label">{{ data.label }}</span>
|
13
|
+
<span v-else-if="data.id">Item {{ data.id }}</span>
|
14
|
+
<span v-else>Item {{ index }}</span>
|
15
|
+
</a>
|
16
|
+
</div>
|
17
|
+
<div v-bind:id="'collapse-' + index" class="collapse card-body" @change="onUpdate">
|
18
|
+
<!--
|
19
|
+
<br>data ###########################
|
20
|
+
{{data}}
|
21
|
+
<br>item ###########################
|
22
|
+
{{item}}
|
23
|
+
<br>object ###########################
|
24
|
+
{{object}}-->
|
25
|
+
<DefaultInput
|
26
|
+
v-for="(property, name) in spec.properties"
|
27
|
+
:key="property.name"
|
28
|
+
v-bind:name="name"
|
29
|
+
v-bind:item="data"
|
30
|
+
v-bind:spec="property"></DefaultInput>
|
31
|
+
</div>
|
32
|
+
</div>
|
33
|
+
</Draggable>
|
34
|
+
</template>
|
35
|
+
|
36
|
+
<script>
|
37
|
+
import DefaultInput from './DefaultInput.vue'
|
38
|
+
import Draggable from 'vuedraggable'
|
39
|
+
|
40
|
+
export default {
|
41
|
+
props: {
|
42
|
+
item: {
|
43
|
+
type: Object
|
44
|
+
},
|
45
|
+
scope: {
|
46
|
+
default: 'items',
|
47
|
+
type: String
|
48
|
+
},
|
49
|
+
spec: {
|
50
|
+
type: Object
|
51
|
+
}
|
52
|
+
},
|
53
|
+
components: {
|
54
|
+
DefaultInput, Draggable
|
55
|
+
},
|
56
|
+
created() {
|
57
|
+
// console.log('SortableInputArray', this, this.object, this.scope, this.spec)
|
58
|
+
},
|
59
|
+
data() {
|
60
|
+
return {
|
61
|
+
id: this.randomString(10),
|
62
|
+
object: this.item && this.item[this.scope] ? this.item[this.scope] : []
|
63
|
+
}
|
64
|
+
},
|
65
|
+
methods: {
|
66
|
+
onUpdate() {
|
67
|
+
// console.log('FormList onUpdate', this)
|
68
|
+
|
69
|
+
// FIXME: This is evil, but can't seem to to trigger the right event
|
70
|
+
this.$set(this.$parent.item.values, this.scope, this.object)
|
71
|
+
}
|
72
|
+
}
|
73
|
+
}
|
74
|
+
</script>
|
@@ -0,0 +1,65 @@
|
|
1
|
+
import SimpleMDE from 'simplemde/dist/simplemde.min'
|
2
|
+
|
3
|
+
export default class Editor {
|
4
|
+
constructor(element) {
|
5
|
+
let self = this
|
6
|
+
|
7
|
+
this.mde = new SimpleMDE({
|
8
|
+
element: element,
|
9
|
+
forceSync: true,
|
10
|
+
autoDownloadFontAwesome: false,
|
11
|
+
toolbar: ['bold', 'italic', 'strikethrough', 'heading', '|',
|
12
|
+
'unordered-list', 'ordered-list', 'quote', 'code', 'table', 'link', '|',
|
13
|
+
'clean-block', 'fullscreen']
|
14
|
+
})
|
15
|
+
|
16
|
+
// console.log(this.mde.gui.toolbar)
|
17
|
+
$(this.mde.gui.toolbar).find('.fa-header').attr('class', 'fa fa-heading')
|
18
|
+
|
19
|
+
this.mde.codemirror.on('drop', function(cm, event) {
|
20
|
+
const id = event.dataTransfer.getData('id')
|
21
|
+
const embed = event.dataTransfer.getData('embed')
|
22
|
+
console.log('handleDrop', cm, event, id, embed)
|
23
|
+
|
24
|
+
var coords = cm.coordsChar({ left: event.x, top: event.y })
|
25
|
+
cm.replaceRange(embed, coords)
|
26
|
+
event.preventDefault()
|
27
|
+
})
|
28
|
+
}
|
29
|
+
destroy() {
|
30
|
+
this.mde.toTextArea()
|
31
|
+
}
|
32
|
+
refresh() {
|
33
|
+
var self = this
|
34
|
+
setTimeout(function() { self.mde.codemirror.refresh() }, 0)
|
35
|
+
}
|
36
|
+
value() {
|
37
|
+
return this.mde.value()
|
38
|
+
}
|
39
|
+
toggleFullscreen() {
|
40
|
+
const elem = this.mde.options.element || document.documentElement;
|
41
|
+
if (!document.fullscreenElement && !document.mozFullScreenElement &&
|
42
|
+
!document.webkitFullscreenElement && !document.msFullscreenElement) {
|
43
|
+
if (elem.requestFullscreen) {
|
44
|
+
elem.requestFullscreen();
|
45
|
+
} else if (elem.msRequestFullscreen) {
|
46
|
+
elem.msRequestFullscreen();
|
47
|
+
} else if (elem.mozRequestFullScreen) {
|
48
|
+
elem.mozRequestFullScreen();
|
49
|
+
} else if (elem.webkitRequestFullscreen) {
|
50
|
+
elem.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
|
51
|
+
}
|
52
|
+
}
|
53
|
+
else {
|
54
|
+
if (document.exitFullscreen) {
|
55
|
+
document.exitFullscreen();
|
56
|
+
} else if (document.msExitFullscreen) {
|
57
|
+
document.msExitFullscreen();
|
58
|
+
} else if (document.mozCancelFullScreen) {
|
59
|
+
document.mozCancelFullScreen();
|
60
|
+
} else if (document.webkitExitFullscreen) {
|
61
|
+
document.webkitExitFullscreen();
|
62
|
+
}
|
63
|
+
}
|
64
|
+
}
|
65
|
+
}
|
@@ -0,0 +1,73 @@
|
|
1
|
+
import Editor from './editor'
|
2
|
+
|
3
|
+
export default {
|
4
|
+
install(Vue, options) {
|
5
|
+
|
6
|
+
// Inject reusable component options
|
7
|
+
Vue.mixin({
|
8
|
+
methods: {
|
9
|
+
openMarkdownEditor(textareaSelector) {
|
10
|
+
let $textarea = $(textareaSelector)
|
11
|
+
const $modal = $('#markdown-editor-modal')
|
12
|
+
|
13
|
+
let editor = new Editor($modal.find('textarea')[0])
|
14
|
+
editor.mde.value($textarea.val())
|
15
|
+
$modal.modal('show')
|
16
|
+
|
17
|
+
function updateEditorHeight() {
|
18
|
+
$modal.find('.CodeMirror').height($modal.height())
|
19
|
+
}
|
20
|
+
updateEditorHeight()
|
21
|
+
$(window).on('resize', updateEditorHeight)
|
22
|
+
|
23
|
+
editor.mde.codemirror.on('change', function() {
|
24
|
+
$textarea.val(editor.value())
|
25
|
+
})
|
26
|
+
|
27
|
+
$modal.on('hidden.bs.modal', function(e) {
|
28
|
+
editor.destroy()
|
29
|
+
$(window).off('resize', updateEditorHeight)
|
30
|
+
})
|
31
|
+
},
|
32
|
+
|
33
|
+
copyToClipboard(text) {
|
34
|
+
const dummy = document.createElement('input')
|
35
|
+
document.body.appendChild(dummy)
|
36
|
+
dummy.value = text
|
37
|
+
dummy.select()
|
38
|
+
document.execCommand('copy', false)
|
39
|
+
dummy.remove()
|
40
|
+
toastr.info("Copied!")
|
41
|
+
console.log('copied', text)
|
42
|
+
},
|
43
|
+
|
44
|
+
getEmbedCode(itemId) {
|
45
|
+
return '<%= designer_embed("' + itemId + '") %>'
|
46
|
+
},
|
47
|
+
|
48
|
+
// Generate a random alphanumeric string
|
49
|
+
randomString(len) {
|
50
|
+
const p = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
51
|
+
return [...Array(len)].reduce(a=>a+p[~~(Math.random()*p.length)],'')
|
52
|
+
}
|
53
|
+
}
|
54
|
+
})
|
55
|
+
|
56
|
+
Vue.filter('formatSize', (size) => {
|
57
|
+
if (size > 1024 * 1024 * 1024 * 1024) {
|
58
|
+
return (size / 1024 / 1024 / 1024 / 1024).toFixed(2) + ' TB'
|
59
|
+
} else if (size > 1024 * 1024 * 1024) {
|
60
|
+
return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
61
|
+
} else if (size > 1024 * 1024) {
|
62
|
+
return (size / 1024 / 1024).toFixed(2) + ' MB'
|
63
|
+
} else if (size > 1024) {
|
64
|
+
return (size / 1024).toFixed(2) + ' KB'
|
65
|
+
}
|
66
|
+
return size.toString() + ' B'
|
67
|
+
})
|
68
|
+
|
69
|
+
Vue.filter('titleize', (str) => {
|
70
|
+
return str.replace(/[A-Z]/g, ' $&').replace(/_/g, ' ').replace(/^./, str => str.toUpperCase())
|
71
|
+
})
|
72
|
+
}
|
73
|
+
}
|
@@ -0,0 +1,136 @@
|
|
1
|
+
import $ from 'jquery/dist/jquery'
|
2
|
+
// import Rails from 'rails-ujs/lib/assets/compiled/rails-ujs'
|
3
|
+
import 'popper.js/dist/popper'
|
4
|
+
import 'bootstrap/dist/js/bootstrap'
|
5
|
+
import 'jquery-serializejson/jquery.serializejson'
|
6
|
+
import 'jquery-resizable-dom/src/jquery-resizable'
|
7
|
+
import * as toastr from 'toastr/toastr'
|
8
|
+
import Vue from 'vue/dist/vue.esm.js'
|
9
|
+
|
10
|
+
import MediaGallery from '../designer/components/MediaGallery.vue'
|
11
|
+
import DefaultForm from '../designer/components/DefaultForm.vue'
|
12
|
+
|
13
|
+
import Draggable from 'vuedraggable'
|
14
|
+
import Helpers from '../designer/helpers'
|
15
|
+
|
16
|
+
|
17
|
+
export default {
|
18
|
+
start() {
|
19
|
+
|
20
|
+
// Set globals for erb.js callbacks
|
21
|
+
window.$ = $
|
22
|
+
window.toastr = toastr
|
23
|
+
|
24
|
+
toastr.options = {
|
25
|
+
"positionClass": "toast-bottom-right"
|
26
|
+
}
|
27
|
+
|
28
|
+
// Load the Vue app on DOMContentLoaded
|
29
|
+
if (/complete|interactive|loaded/.test(document.readyState)) {
|
30
|
+
this.load()
|
31
|
+
} else {
|
32
|
+
document.addEventListener('DOMContentLoaded', this.load, false)
|
33
|
+
}
|
34
|
+
},
|
35
|
+
load() {
|
36
|
+
// document.addEventListener('DOMContentLoaded', () => {
|
37
|
+
|
38
|
+
Vue.use(Helpers)
|
39
|
+
|
40
|
+
this.app = new Vue({
|
41
|
+
el: '#app',
|
42
|
+
components: {
|
43
|
+
MediaGallery, Draggable, DefaultForm
|
44
|
+
},
|
45
|
+
props: ['elements', 'spec', 'resource_id', 'resource_type', 'resource_path', 'preview_path', 'upload_path'],
|
46
|
+
beforeMount() {
|
47
|
+
console.log('before mount', this.$attrs, this.$el.dataset.elements, this.$el.dataset.spec)
|
48
|
+
const dataset = this.$el.dataset
|
49
|
+
for(const key in dataset) {
|
50
|
+
this[key] = (dataset[key][0] === '{' || dataset[key][0] === '[') ? JSON.parse(dataset[key]) : dataset[key]
|
51
|
+
}
|
52
|
+
this.elements = this.filterMetadata()
|
53
|
+
},
|
54
|
+
data() {
|
55
|
+
return {
|
56
|
+
showPreview: true
|
57
|
+
}
|
58
|
+
},
|
59
|
+
methods: {
|
60
|
+
getSpec(template) {
|
61
|
+
return Object.values(this.spec).find(function(item) {
|
62
|
+
return item.template == template
|
63
|
+
})
|
64
|
+
},
|
65
|
+
filterMetadata() {
|
66
|
+
var self = this
|
67
|
+
if (!Array.isArray(this.elements))
|
68
|
+
return []
|
69
|
+
return this.elements.map(function(item) {
|
70
|
+
if (!item.id)
|
71
|
+
item.id = item.template + '-' + self.randomString(10)
|
72
|
+
const found = Object.values(self.spec).find(function(definition) {
|
73
|
+
return definition.template == item.template
|
74
|
+
})
|
75
|
+
if (found) {
|
76
|
+
return item
|
77
|
+
} else {
|
78
|
+
alert('No template exists for: ' + JSON.stringify(item))
|
79
|
+
return null
|
80
|
+
}
|
81
|
+
}).filter(Boolean)
|
82
|
+
},
|
83
|
+
addElement(template) {
|
84
|
+
this.elements.push({
|
85
|
+
id: template + '-' + this.randomString(10),
|
86
|
+
template: template,
|
87
|
+
values: {}
|
88
|
+
})
|
89
|
+
},
|
90
|
+
save() {
|
91
|
+
console.log('saving', this.elements)
|
92
|
+
let self = this
|
93
|
+
let data = $('#editor form').serializeJSON()
|
94
|
+
if (!data || !data.resource)
|
95
|
+
data = { resource: {} }
|
96
|
+
data.resource.elements = JSON.stringify(this.elements)
|
97
|
+
|
98
|
+
return $.ajax({
|
99
|
+
method: 'PATCH',
|
100
|
+
url: this.resource_path,
|
101
|
+
data: data
|
102
|
+
}).done((result) => {
|
103
|
+
console.log('saved', result)
|
104
|
+
self.refreshPreview()
|
105
|
+
})
|
106
|
+
// return false
|
107
|
+
},
|
108
|
+
refreshPreview() {
|
109
|
+
document.getElementById('preview').contentWindow.location.reload()
|
110
|
+
}
|
111
|
+
}
|
112
|
+
})
|
113
|
+
|
114
|
+
//
|
115
|
+
// Resizable Panels
|
116
|
+
// TODO: Use Vue
|
117
|
+
|
118
|
+
$('#sidebar').resizable({
|
119
|
+
handleSelector: '.splitter',
|
120
|
+
resizeHeight: false
|
121
|
+
})
|
122
|
+
|
123
|
+
//
|
124
|
+
// Markdown Editor
|
125
|
+
|
126
|
+
// var $markdown = $('textarea.markdown')
|
127
|
+
// if ($markdown.length) {
|
128
|
+
// let editor = new Editor($markdown[0])
|
129
|
+
//
|
130
|
+
// $('#sidebar [rel="editor"]').click(() => {
|
131
|
+
// editor.refresh() // ensure content is redered
|
132
|
+
// })
|
133
|
+
// }
|
134
|
+
// })
|
135
|
+
}
|
136
|
+
}
|
@@ -0,0 +1,13 @@
|
|
1
|
+
<% if flash.any? %>
|
2
|
+
toastr.clear()
|
3
|
+
<% end %>
|
4
|
+
|
5
|
+
<% flash.each do |type, message| %>
|
6
|
+
<% if type == 'error' %>
|
7
|
+
toastr.error("<%= message %>")
|
8
|
+
<% elsif type == 'notice' %>
|
9
|
+
toastr.success("<%= message %>")
|
10
|
+
<% else %>
|
11
|
+
toastr.info("<%= message %>")
|
12
|
+
<% end %>
|
13
|
+
<% end %>
|
@@ -0,0 +1,88 @@
|
|
1
|
+
- resource_id ||= @resource.id
|
2
|
+
- resource_type ||= @resource.class.name
|
3
|
+
- elements ||= @resource.elements.to_json
|
4
|
+
- spec ||= designer_option(:spec).to_json
|
5
|
+
- resource_path ||= editor_path(id: @resource.id, resource_name: resource_name)
|
6
|
+
- uploads_path ||= images_path(id: @resource.id, resource_name: resource_name)
|
7
|
+
- preview_path ||= designer_preview_path
|
8
|
+
|
9
|
+
#app {
|
10
|
+
data-resource_id=(resource_id.to_json)
|
11
|
+
data-resource_type=(resource_type)
|
12
|
+
data-elements=(elements)
|
13
|
+
data-spec=(spec)
|
14
|
+
data-resource_path=(resource_path)
|
15
|
+
data-preview_path=(preview_path)
|
16
|
+
data-uploads_path=(uploads_path)
|
17
|
+
}
|
18
|
+
|
19
|
+
#sidebar :class="{'panel-left': true, 'w-100': !showPreview}"
|
20
|
+
ul#menu.nav.nav-tabs role="tablist"
|
21
|
+
li.nav-item
|
22
|
+
a.nav-link.active data-toggle="tab" href="#elements" role="tab" Elements
|
23
|
+
li.nav-item
|
24
|
+
a.nav-link data-toggle="tab" href="#gallery" role="tab" Gallery
|
25
|
+
- if lookup_context.exists?('nav', designer_option(:designer_template_path), true)
|
26
|
+
= render "#{designer_option(:designer_template_path)}/nav", resource: @resource
|
27
|
+
li.nav-item
|
28
|
+
a.nav-link data-toggle="tab" href="#editor" role="tab" Editor
|
29
|
+
/ = yield(:designer_nav)
|
30
|
+
li.nav-item.flex-fill
|
31
|
+
li.nav-item
|
32
|
+
a.nav-link href="#" @click="refreshPreview()" title="Refresh Preview"
|
33
|
+
i class="fa fa-sync"
|
34
|
+
li.nav-item
|
35
|
+
a.nav-link href="#" @click="save()" title="Save"
|
36
|
+
i class="fa fa-save"
|
37
|
+
/li.nav-item
|
38
|
+
a :class="{'nav-link': true, active: currentView == 'editor'}" href="#" @click="currentView = 'editor'" rel="editor" title="Editor"
|
39
|
+
i class="fa fa-edit"
|
40
|
+
li.nav-item
|
41
|
+
a :class="{'nav-link': true, active: showPreview}" href="#" @click="showPreview = !showPreview" rel="preview" title="Preview"
|
42
|
+
i class="fa fa-eye"
|
43
|
+
.tab-content
|
44
|
+
#elements.tab-pane.fade.show.active
|
45
|
+
.row.align-items-center
|
46
|
+
.col-6
|
47
|
+
/ .tab-title.m-0.pl-1 Page Designer
|
48
|
+
.col-6.text-right
|
49
|
+
.dropdown.mr-2.my-2
|
50
|
+
button.btn.btn-sm.btn-success.dropdown-toggle data-toggle="dropdown" type="button" Add
|
51
|
+
.dropdown-menu
|
52
|
+
a.dropdown-item v-for="definition in spec" @click="addElement(definition.template)" href="#"= '{{definition.label}}'
|
53
|
+
Draggable v-model="elements" :options="{handle:'.drag-handle'}"
|
54
|
+
component {
|
55
|
+
:is="item.component || 'DefaultForm'"
|
56
|
+
v-for="(item, index) in elements" :key="item.id"
|
57
|
+
v-bind:item="item"
|
58
|
+
v-bind:spec="getSpec(item.template)"
|
59
|
+
v-on:remove="elements.splice(index, 1)" }
|
60
|
+
#gallery.tab-pane.fade aria-labelledby="gallery-tab" role="tabpanel"
|
61
|
+
component is="MediaGallery" v-bind:uploads_path="uploads_path"
|
62
|
+
- if lookup_context.exists?('form', designer_option(:designer_template_path), true)
|
63
|
+
/ #editor.panel-right v-show="currentView == 'editor'"
|
64
|
+
#editor.tab-pane.fade.p-3 aria-labelledby="editor-tab" role="tabpanel"
|
65
|
+
= render "#{designer_option(:designer_template_path)}/form", resource: @resource, resource_path: resource_path
|
66
|
+
/- if lookup_context.exists?('tabs', designer_option(:designer_template_path), true)
|
67
|
+
= render "#{designer_option(:designer_template_path)}/tabs", resource: @resource
|
68
|
+
/ = yield(:designer_tabs)
|
69
|
+
.splitter
|
70
|
+
/= yield(:designer_form)
|
71
|
+
/ height="100%" width="100%" src=(preview_path)
|
72
|
+
iframe#preview.panel-right v-show="showPreview" height="100%" width="100%" src=(preview_path)
|
73
|
+
|
74
|
+
/ Markdown Editor
|
75
|
+
#markdown-editor-modal.modal role="dialog" tabindex="-1"
|
76
|
+
.modal-dialog role="document"
|
77
|
+
.modal-content
|
78
|
+
/.modal-header
|
79
|
+
h5.modal-title Modal title
|
80
|
+
button.close aria-label="Close" data-dismiss="modal" type="button"
|
81
|
+
span aria-hidden="true" ×
|
82
|
+
.modal-body.p-0.d-flex.flex-column
|
83
|
+
button.close aria-label="Close" data-dismiss="modal" type="button"
|
84
|
+
span aria-hidden="true" ×
|
85
|
+
textarea.flex-fill
|
86
|
+
/.modal-footer
|
87
|
+
button.btn.btn-primary type="button" Save changes
|
88
|
+
button.btn.btn-secondary data-dismiss="modal" type="button" Close
|