1142 lines
39 KiB
Mako
1142 lines
39 KiB
Mako
## -*- coding: utf-8; -*-
|
|
<%namespace name="base_meta" file="/base_meta.mako" />
|
|
<%namespace name="page_help" file="/page_help.mako" />
|
|
<%namespace file="/field-components.mako" import="make_field_components" />
|
|
<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
|
|
<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
|
|
<%namespace file="/buefy-components.mako" import="make_buefy_components" />
|
|
<%namespace file="/buefy-plugin.mako" import="make_buefy_plugin" />
|
|
<%namespace file="/http-plugin.mako" import="make_http_plugin" />
|
|
## <%namespace file="/grids/nav.mako" import="grid_index_nav" />
|
|
## <%namespace name="multi_file_upload" file="/multi_file_upload.mako" />
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
|
|
<title>${base_meta.global_title()} » ${capture(self.title)|n}</title>
|
|
${base_meta.favicon()}
|
|
${self.header_core()}
|
|
${self.head_tags()}
|
|
</head>
|
|
|
|
<body>
|
|
<div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
|
|
<whole-page></whole-page>
|
|
</div>
|
|
|
|
## TODO: this must come before the self.body() call..but why?
|
|
${declare_formposter_mixin()}
|
|
|
|
## global components used by various (but not all) pages
|
|
${make_field_components()}
|
|
${make_grid_filter_components()}
|
|
|
|
## global components for buefy-based template compatibility
|
|
${make_http_plugin()}
|
|
${make_buefy_plugin()}
|
|
${make_buefy_components()}
|
|
|
|
## special global components, used by WholePage
|
|
${self.make_menu_search_component()}
|
|
${page_help.render_template()}
|
|
${page_help.declare_vars()}
|
|
% if request.has_perm('common.feedback'):
|
|
${self.make_feedback_component()}
|
|
% endif
|
|
|
|
## WholePage component
|
|
${self.make_whole_page_component()}
|
|
|
|
## content body from derived/child template
|
|
${self.body()}
|
|
|
|
## Vue app
|
|
${self.make_whole_page_app()}
|
|
</body>
|
|
</html>
|
|
|
|
<%def name="title()"></%def>
|
|
|
|
<%def name="content_title()">
|
|
${self.title()}
|
|
</%def>
|
|
|
|
<%def name="header_core()">
|
|
${self.core_javascript()}
|
|
${self.core_styles()}
|
|
</%def>
|
|
|
|
<%def name="core_javascript()">
|
|
<script type="importmap">
|
|
{
|
|
## TODO: eventually version / url should be configurable
|
|
"imports": {
|
|
"vue": "${h.get_liburl(request, 'bb_vue')}",
|
|
"@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}",
|
|
"@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}",
|
|
"@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}",
|
|
"@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}",
|
|
"@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}"
|
|
}
|
|
}
|
|
</script>
|
|
<script>
|
|
// empty stub to avoid errors for older buefy templates
|
|
const Vue = {
|
|
component(tagname, classname) {},
|
|
}
|
|
</script>
|
|
</%def>
|
|
|
|
<%def name="core_styles()">
|
|
|
|
## ## TODO: eventually, allow custom css per-user
|
|
## % if user_css:
|
|
## ${h.stylesheet_link(user_css)}
|
|
## % else:
|
|
## ${h.stylesheet_link(h.get_liburl(request, 'bulma.css'))}
|
|
## % endif
|
|
|
|
## TODO: eventually version / url should be configurable
|
|
${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))}
|
|
|
|
</%def>
|
|
|
|
<%def name="head_tags()">
|
|
${self.extra_javascript()}
|
|
${self.extra_styles()}
|
|
</%def>
|
|
|
|
<%def name="extra_javascript()">
|
|
## ## some commonly-useful logic for detecting (non-)numeric input
|
|
## ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))}
|
|
##
|
|
## ## debounce, for better autocomplete performance
|
|
## ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))}
|
|
|
|
## ## Tailbone / Buefy stuff
|
|
## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))}
|
|
## ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))}
|
|
|
|
## <script type="text/javascript">
|
|
##
|
|
## ## NOTE: this code was copied from
|
|
## ## https://bulma.io/documentation/components/navbar/#navbar-menu
|
|
##
|
|
## document.addEventListener('DOMContentLoaded', () => {
|
|
##
|
|
## // Get all "navbar-burger" elements
|
|
## const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0)
|
|
##
|
|
## // Add a click event on each of them
|
|
## $navbarBurgers.forEach( el => {
|
|
## el.addEventListener('click', () => {
|
|
##
|
|
## // Get the target from the "data-target" attribute
|
|
## const target = el.dataset.target
|
|
## const $target = document.getElementById(target)
|
|
##
|
|
## // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
|
|
## el.classList.toggle('is-active')
|
|
## $target.classList.toggle('is-active')
|
|
##
|
|
## })
|
|
## })
|
|
## })
|
|
##
|
|
## </script>
|
|
</%def>
|
|
|
|
<%def name="extra_styles()">
|
|
|
|
## ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))}
|
|
## ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
|
|
## ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
|
|
## ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
|
|
## ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
|
|
|
|
${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))}
|
|
${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
|
|
|
|
## nb. this is used (only?) in /generate-feature page
|
|
${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))}
|
|
|
|
<style>
|
|
|
|
/* ****************************** */
|
|
/* page */
|
|
/* ****************************** */
|
|
|
|
/* nb. helps force footer to bottom of screen */
|
|
html, body {
|
|
height: 100%;
|
|
}
|
|
|
|
## maybe add testing watermark
|
|
% if not request.rattail_config.production():
|
|
html, .navbar, .footer {
|
|
background-image: url(${request.static_url('tailbone:static/img/testing.png')});
|
|
}
|
|
% endif
|
|
|
|
## maybe force global background color
|
|
% if background_color:
|
|
body, .navbar, .footer {
|
|
background-color: ${background_color};
|
|
}
|
|
% endif
|
|
|
|
## TODO: is this a good idea?
|
|
h1.title {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
margin-bottom: 0 !important;
|
|
}
|
|
|
|
#context-menu {
|
|
margin-bottom: 1em;
|
|
/* margin-left: 1em; */
|
|
text-align: right;
|
|
/* white-space: nowrap; */
|
|
}
|
|
|
|
## TODO: ugh why is this needed to center modal on screen?
|
|
.modal .modal-content .modal-card {
|
|
margin: auto;
|
|
}
|
|
|
|
.object-helpers .panel {
|
|
margin: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
/* ****************************** */
|
|
/* grids */
|
|
/* ****************************** */
|
|
|
|
.filters .filter-fieldname .button {
|
|
min-width: ${filter_fieldname_width};
|
|
justify-content: left;
|
|
}
|
|
.filters .filter-verb {
|
|
min-width: ${filter_verb_width};
|
|
}
|
|
|
|
.grid-tools {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: end;
|
|
}
|
|
|
|
a.grid-action {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ****************************** */
|
|
/* forms */
|
|
/* ****************************** */
|
|
|
|
/* note that these should only apply to "normal" primary forms */
|
|
|
|
.form {
|
|
padding-left: 5em;
|
|
}
|
|
|
|
/* .form-wrapper .form .field.is-horizontal .field-label .label, */
|
|
.form-wrapper .form .field.is-horizontal .field-label {
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
min-width: 18em;
|
|
}
|
|
|
|
.form-wrapper .form .field.is-horizontal .field-body {
|
|
min-width: 30em;
|
|
}
|
|
|
|
.form-wrapper .form .field.is-horizontal .field-body .autocomplete,
|
|
.form-wrapper .form .field.is-horizontal .field-body .autocomplete .dropdown-trigger,
|
|
.form-wrapper .form .field.is-horizontal .field-body .select,
|
|
.form-wrapper .form .field.is-horizontal .field-body .select select {
|
|
width: 100%;
|
|
}
|
|
|
|
.form-wrapper .form .buttons {
|
|
padding-left: 10rem;
|
|
}
|
|
|
|
</style>
|
|
</%def>
|
|
|
|
<%def name="make_feedback_component()">
|
|
<% request.register_component('feedback-form', 'FeedbackForm') %>
|
|
<script type="text/x-template" id="feedback-form-template">
|
|
<div>
|
|
|
|
<o-button variant="primary"
|
|
@click="showFeedback()"
|
|
icon-left="comment">
|
|
Feedback
|
|
</o-button>
|
|
|
|
<o-modal v-model:active="showDialog">
|
|
<div class="modal-card">
|
|
|
|
<header class="modal-card-head">
|
|
<p class="modal-card-title">
|
|
User Feedback
|
|
</p>
|
|
</header>
|
|
|
|
<section class="modal-card-body">
|
|
<p class="block">
|
|
Questions, suggestions, comments, complaints, etc.
|
|
<span class="red">regarding this website</span> are
|
|
welcome and may be submitted below.
|
|
</p>
|
|
|
|
<b-field label="User Name">
|
|
<b-input v-model="userName"
|
|
% if request.user:
|
|
disabled
|
|
% endif
|
|
expanded>
|
|
</b-input>
|
|
</b-field>
|
|
|
|
<b-field label="Referring URL">
|
|
<b-input
|
|
v-model="referrer"
|
|
disabled expanded>
|
|
</b-input>
|
|
</b-field>
|
|
|
|
<o-field label="Message">
|
|
<o-input type="textarea"
|
|
v-model="message"
|
|
ref="message"
|
|
expanded>
|
|
</o-input>
|
|
</o-field>
|
|
|
|
% if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'):
|
|
<div class="level">
|
|
<div class="level-left">
|
|
<div class="level-item">
|
|
<b-checkbox v-model="pleaseReply"
|
|
@input="pleaseReplyChanged">
|
|
Please email me back{{ pleaseReply ? " at: " : "" }}
|
|
</b-checkbox>
|
|
</div>
|
|
<div class="level-item" v-show="pleaseReply">
|
|
<b-input v-model="userEmail"
|
|
ref="userEmail">
|
|
</b-input>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
% endif
|
|
|
|
</section>
|
|
|
|
<footer class="modal-card-foot">
|
|
<o-button @click="showDialog = false">
|
|
Cancel
|
|
</o-button>
|
|
<o-button variant="primary"
|
|
@click="sendFeedback()"
|
|
:disabled="sending || !message?.trim()">
|
|
{{ sending ? "Working, please wait..." : "Send Message" }}
|
|
</o-button>
|
|
</footer>
|
|
</div>
|
|
</o-modal>
|
|
</div>
|
|
</script>
|
|
<script>
|
|
|
|
const FeedbackForm = {
|
|
template: '#feedback-form-template',
|
|
mixins: [SimpleRequestMixin],
|
|
|
|
props: {
|
|
action: String,
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
referrer: null,
|
|
% if request.user:
|
|
userUUID: ${json.dumps(request.user.uuid)|n},
|
|
userName: ${json.dumps(six.text_type(request.user))|n},
|
|
% else:
|
|
userUUID: null,
|
|
userName: null,
|
|
% endif
|
|
message: null,
|
|
pleaseReply: false,
|
|
userEmail: null,
|
|
showDialog: false,
|
|
sending: false,
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
|
|
pleaseReplyChanged(value) {
|
|
this.$nextTick(() => {
|
|
this.$refs.userEmail.focus()
|
|
})
|
|
},
|
|
|
|
showFeedback() {
|
|
this.referrer = location.href
|
|
this.message = null
|
|
this.showDialog = true
|
|
this.$nextTick(function() {
|
|
this.$refs.message.focus()
|
|
})
|
|
},
|
|
|
|
sendFeedback() {
|
|
this.sending = true
|
|
|
|
const params = {
|
|
referrer: this.referrer,
|
|
user: this.userUUID,
|
|
user_name: this.userName,
|
|
please_reply_to: this.pleaseReply ? this.userEmail : '',
|
|
message: this.message?.trim(),
|
|
}
|
|
|
|
this.simplePOST(this.action, params, response => {
|
|
|
|
this.$buefy.toast.open({
|
|
message: "Message sent! Thank you for your feedback.",
|
|
type: 'is-info',
|
|
duration: 4000, // 4 seconds
|
|
})
|
|
|
|
this.sending = false
|
|
this.showDialog = false
|
|
|
|
}, response => {
|
|
this.sending = false
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
</script>
|
|
</%def>
|
|
|
|
<%def name="make_menu_search_component()">
|
|
<% request.register_component('menu-search', 'MenuSearch') %>
|
|
<script type="text/x-template" id="menu-search-template">
|
|
<div>
|
|
|
|
<a v-show="!searchActive"
|
|
href="${url('home')}"
|
|
class="navbar-item">
|
|
${base_meta.header_logo()}
|
|
<div id="global-header-title">
|
|
${base_meta.global_title()}
|
|
</div>
|
|
</a>
|
|
|
|
<div v-show="searchActive"
|
|
class="navbar-item">
|
|
<o-autocomplete ref="searchAutocomplete"
|
|
v-model="searchTerm"
|
|
:data="searchFilteredData"
|
|
field="label"
|
|
open-on-focus
|
|
keep-first
|
|
icon-pack="fas"
|
|
clearable
|
|
@select="searchSelect">
|
|
</o-autocomplete>
|
|
</div>
|
|
</div>
|
|
</script>
|
|
<script>
|
|
|
|
const MenuSearch = {
|
|
template: '#menu-search-template',
|
|
|
|
props: {
|
|
searchData: Array,
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
searchActive: false,
|
|
searchTerm: null,
|
|
searchInput: null,
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
|
|
searchFilteredData() {
|
|
if (!this.searchTerm || !this.searchTerm.length) {
|
|
return this.searchData
|
|
}
|
|
|
|
let terms = []
|
|
for (let term of this.searchTerm.toLowerCase().split(' ')) {
|
|
term = term.trim()
|
|
if (term) {
|
|
terms.push(term)
|
|
}
|
|
}
|
|
if (!terms.length) {
|
|
return this.searchData
|
|
}
|
|
|
|
// all terms must match
|
|
return this.searchData.filter((option) => {
|
|
let label = option.label.toLowerCase()
|
|
for (let term of terms) {
|
|
if (label.indexOf(term) < 0) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
},
|
|
},
|
|
|
|
mounted() {
|
|
this.searchInput = this.$refs.searchAutocomplete.$el.querySelector('input')
|
|
this.searchInput.addEventListener('keydown', this.searchKeydown)
|
|
},
|
|
|
|
beforeDestroy() {
|
|
this.searchInput.removeEventListener('keydown', this.searchKeydown)
|
|
},
|
|
|
|
methods: {
|
|
|
|
searchInit() {
|
|
this.searchTerm = ''
|
|
this.searchActive = true
|
|
this.$nextTick(() => {
|
|
this.$refs.searchAutocomplete.focus()
|
|
})
|
|
},
|
|
|
|
searchKeydown(event) {
|
|
// ESC will dismiss searchbox
|
|
if (event.which == 27) {
|
|
this.searchActive = false
|
|
}
|
|
},
|
|
|
|
searchSelect(option) {
|
|
location.href = option.url
|
|
},
|
|
},
|
|
}
|
|
|
|
</script>
|
|
</%def>
|
|
|
|
<%def name="render_whole_page_template()">
|
|
<script type="text/x-template" id="whole-page-template">
|
|
<div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
|
|
|
|
<div class="header-wrapper">
|
|
|
|
<header>
|
|
|
|
<!-- this main menu, with search -->
|
|
<nav class="navbar" role="navigation" aria-label="main navigation">
|
|
|
|
<div class="navbar-brand">
|
|
<menu-search :search-data="globalSearchData"
|
|
ref="menuSearch" />
|
|
<a role="button" class="navbar-burger" data-target="navbarMenu" aria-label="menu" aria-expanded="false">
|
|
<span aria-hidden="true"></span>
|
|
<span aria-hidden="true"></span>
|
|
<span aria-hidden="true"></span>
|
|
<span aria-hidden="true"></span>
|
|
</a>
|
|
</div>
|
|
|
|
<div class="navbar-menu" id="navbarMenu">
|
|
<div class="navbar-start">
|
|
|
|
## global search button
|
|
<div v-if="globalSearchData.length"
|
|
class="navbar-item">
|
|
<o-button variant="primary"
|
|
size="small"
|
|
@click="globalSearchInit()">
|
|
<o-icon icon="search" size="small" />
|
|
</o-button>
|
|
</div>
|
|
|
|
## main menu
|
|
% for topitem in menus:
|
|
% if topitem['is_link']:
|
|
${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')}
|
|
% else:
|
|
<div class="navbar-item has-dropdown is-hoverable">
|
|
<a class="navbar-link">${topitem['title']}</a>
|
|
<div class="navbar-dropdown">
|
|
% for item in topitem['items']:
|
|
% if item['is_menu']:
|
|
<% item_hash = id(item) %>
|
|
<% toggle = f'menu_{item_hash}_shown' %>
|
|
<div>
|
|
<a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')">
|
|
${item['title']}
|
|
</a>
|
|
</div>
|
|
% for subitem in item['items']:
|
|
% if subitem['is_sep']:
|
|
<hr class="navbar-divider" v-show="${toggle}">
|
|
% else:
|
|
${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})}
|
|
% endif
|
|
% endfor
|
|
% else:
|
|
% if item['is_sep']:
|
|
<hr class="navbar-divider">
|
|
% else:
|
|
${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])}
|
|
% endif
|
|
% endif
|
|
% endfor
|
|
</div>
|
|
</div>
|
|
% endif
|
|
% endfor
|
|
|
|
</div><!-- navbar-start -->
|
|
${self.render_navbar_end()}
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- nb. this has index title, help button etc. -->
|
|
<nav style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem;">
|
|
|
|
## Current Context
|
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
|
% if master:
|
|
% if master.listing:
|
|
<h1 class="title">
|
|
${index_title}
|
|
</h1>
|
|
% if master.creatable and master.show_create_link and master.has_perm('create'):
|
|
<once-button type="is-primary"
|
|
tag="a" href="${url('{}.create'.format(route_prefix))}"
|
|
icon-left="plus"
|
|
style="margin-left: 1rem;"
|
|
text="Create New">
|
|
</once-button>
|
|
% endif
|
|
% elif index_url:
|
|
<h1 class="title">
|
|
${h.link_to(index_title, index_url)}
|
|
</h1>
|
|
% if parent_url is not Undefined:
|
|
<h1 class="title">
|
|
»
|
|
</h1>
|
|
<h1 class="title">
|
|
${h.link_to(parent_title, parent_url)}
|
|
</h1>
|
|
% elif instance_url is not Undefined:
|
|
<h1 class="title">
|
|
»
|
|
</h1>
|
|
<h1 class="title">
|
|
${h.link_to(instance_title, instance_url)}
|
|
</h1>
|
|
% elif master.creatable and master.show_create_link and master.has_perm('create'):
|
|
% if not request.matched_route.name.endswith('.create'):
|
|
<once-button type="is-primary"
|
|
tag="a" href="${url('{}.create'.format(route_prefix))}"
|
|
icon-left="plus"
|
|
style="margin-left: 1rem;"
|
|
text="Create New">
|
|
</once-button>
|
|
% endif
|
|
% endif
|
|
## % if master.viewing and grid_index:
|
|
## ${grid_index_nav()}
|
|
## % endif
|
|
% else:
|
|
<h1 class="title">
|
|
${index_title}
|
|
</h1>
|
|
% endif
|
|
% elif index_title:
|
|
% if index_url:
|
|
<h1 class="title">
|
|
${h.link_to(index_title, index_url)}
|
|
</h1>
|
|
% else:
|
|
<h1 class="title">
|
|
${index_title}
|
|
</h1>
|
|
% endif
|
|
% endif
|
|
|
|
## % if expose_db_picker is not Undefined and expose_db_picker:
|
|
## <div class="level-item">
|
|
## <p>DB:</p>
|
|
## </div>
|
|
## <div class="level-item">
|
|
## ${h.form(url('change_db_engine'), ref='dbPickerForm')}
|
|
## ${h.csrf_token(request)}
|
|
## ${h.hidden('engine_type', value=master.engine_type_key)}
|
|
## <b-select name="dbkey"
|
|
## value="${db_picker_selected}"
|
|
## @input="changeDB()">
|
|
## % for option in db_picker_options:
|
|
## <option value="${option.value}">
|
|
## ${option.label}
|
|
## </option>
|
|
## % endfor
|
|
## </b-select>
|
|
## ${h.end_form()}
|
|
## </div>
|
|
## % endif
|
|
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
|
|
## ## Quickie Lookup
|
|
## % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
|
|
## <div class="level-item">
|
|
## ${h.form(quickie.url, method="get")}
|
|
## <div class="level">
|
|
## <div class="level-right">
|
|
## <div class="level-item">
|
|
## <b-input name="entry"
|
|
## placeholder="${quickie.placeholder}"
|
|
## autocomplete="off">
|
|
## </b-input>
|
|
## </div>
|
|
## <div class="level-item">
|
|
## <button type="submit" class="button is-primary">
|
|
## <span class="icon is-small">
|
|
## <i class="fas fa-search"></i>
|
|
## </span>
|
|
## <span>Lookup</span>
|
|
## </button>
|
|
## </div>
|
|
## </div>
|
|
## </div>
|
|
## ${h.end_form()}
|
|
## </div>
|
|
## % endif
|
|
|
|
% if master and master.configurable and master.has_perm('configure'):
|
|
% if not request.matched_route.name.endswith('.configure'):
|
|
<once-button type="is-primary"
|
|
tag="a"
|
|
href="${url('{}.configure'.format(route_prefix))}"
|
|
icon-left="cog"
|
|
text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}">
|
|
</once-button>
|
|
% endif
|
|
% endif
|
|
|
|
## Theme Picker
|
|
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
|
|
${h.form(url('change_theme'), method="post", ref='themePickerForm')}
|
|
${h.csrf_token(request)}
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<span>Theme:</span>
|
|
<b-select name="theme"
|
|
v-model="globalTheme"
|
|
@input="changeTheme()">
|
|
% for option in theme_picker_options:
|
|
<option value="${option.value}">
|
|
${option.label}
|
|
</option>
|
|
% endfor
|
|
</b-select>
|
|
</div>
|
|
${h.end_form()}
|
|
% endif
|
|
|
|
% if help_url or help_markdown or can_edit_help:
|
|
<page-help
|
|
% if can_edit_help:
|
|
@configure-fields-help="configureFieldsHelp = true"
|
|
% endif
|
|
>
|
|
</page-help>
|
|
% endif
|
|
|
|
## Feedback Button / Dialog
|
|
% if request.has_perm('common.feedback'):
|
|
<feedback-form action="${url('feedback')}" />
|
|
% endif
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
## Page Title
|
|
% if capture(self.content_title):
|
|
<section class="has-background-primary"
|
|
## TODO: id is only for css, do we need it?
|
|
id="content-title"
|
|
style="padding: 0.5rem; padding-left: 1rem;">
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
|
|
<h1 class="title has-text-white" v-html="contentTitleHTML" />
|
|
|
|
<div style="flex-grow: 1; display: flex; gap: 0.5rem;">
|
|
${self.render_instance_header_title_extras()}
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
${self.render_instance_header_buttons()}
|
|
</div>
|
|
|
|
</div>
|
|
</section>
|
|
% endif
|
|
|
|
</div> <!-- header-wrapper -->
|
|
|
|
<div class="content-wrapper"
|
|
style="flex-grow: 1; padding: 0.5rem;">
|
|
|
|
## Page Body
|
|
<section id="page-body">
|
|
|
|
% if request.session.peek_flash('error'):
|
|
% for error in request.session.pop_flash('error'):
|
|
<b-notification type="is-warning">
|
|
${error}
|
|
</b-notification>
|
|
% endfor
|
|
% endif
|
|
|
|
% if request.session.peek_flash('warning'):
|
|
% for msg in request.session.pop_flash('warning'):
|
|
<b-notification type="is-warning">
|
|
${msg}
|
|
</b-notification>
|
|
% endfor
|
|
% endif
|
|
|
|
% if request.session.peek_flash():
|
|
% for msg in request.session.pop_flash():
|
|
<b-notification type="is-info">
|
|
${msg}
|
|
</b-notification>
|
|
% endfor
|
|
% endif
|
|
|
|
## true page content
|
|
<div>
|
|
${self.render_this_page_component()}
|
|
</div>
|
|
</section>
|
|
</div><!-- content-wrapper -->
|
|
|
|
## Footer
|
|
<footer class="footer">
|
|
<div class="content">
|
|
${base_meta.footer()}
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</script>
|
|
|
|
## ${multi_file_upload.render_template()}
|
|
</%def>
|
|
|
|
<%def name="render_this_page_component()">
|
|
<this-page @change-content-title="changeContentTitle"
|
|
% if can_edit_help:
|
|
:configure-fields-help="configureFieldsHelp"
|
|
% endif
|
|
>
|
|
</this-page>
|
|
</%def>
|
|
|
|
<%def name="render_navbar_end()">
|
|
<div class="navbar-end">
|
|
${self.render_user_menu()}
|
|
</div>
|
|
</%def>
|
|
|
|
<%def name="render_user_menu()">
|
|
% if request.user:
|
|
<div class="navbar-item has-dropdown is-hoverable">
|
|
% if messaging_enabled:
|
|
<a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a>
|
|
% else:
|
|
<a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a>
|
|
% endif
|
|
<div class="navbar-dropdown">
|
|
% if request.is_root:
|
|
${h.link_to("Stop being root", url('stop_root'), class_='navbar-item has-background-danger has-text-white')}
|
|
% elif request.is_admin:
|
|
${h.link_to("Become root", url('become_root'), class_='navbar-item has-background-danger has-text-white')}
|
|
% endif
|
|
% if messaging_enabled:
|
|
${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
|
|
% endif
|
|
% if request.is_root or not request.user.prevent_password_change:
|
|
${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
|
|
% endif
|
|
${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
|
|
${h.link_to("Logout", url('logout'), class_='navbar-item')}
|
|
</div>
|
|
</div>
|
|
% else:
|
|
${h.link_to("Login", url('login'), class_='navbar-item')}
|
|
% endif
|
|
</%def>
|
|
|
|
<%def name="render_instance_header_title_extras()"></%def>
|
|
|
|
<%def name="render_instance_header_buttons()">
|
|
${self.render_crud_header_buttons()}
|
|
${self.render_prevnext_header_buttons()}
|
|
</%def>
|
|
|
|
<%def name="render_crud_header_buttons()">
|
|
% if master and master.viewing:
|
|
## TODO: is there a better way to check if viewing parent?
|
|
% if parent_instance is Undefined:
|
|
% if master.editable and instance_editable and master.has_perm('edit'):
|
|
<once-button tag="a" href="${action_url('edit', instance)}"
|
|
icon-left="edit"
|
|
text="Edit This">
|
|
</once-button>
|
|
% endif
|
|
% if master.cloneable and master.has_perm('clone'):
|
|
<once-button tag="a" href="${action_url('clone', instance)}"
|
|
icon-left="object-ungroup"
|
|
text="Clone This">
|
|
</once-button>
|
|
% endif
|
|
% if master.deletable and instance_deletable and master.has_perm('delete'):
|
|
<once-button tag="a" href="${action_url('delete', instance)}"
|
|
type="is-danger"
|
|
icon-left="trash"
|
|
text="Delete This">
|
|
</once-button>
|
|
% endif
|
|
% else:
|
|
## viewing row
|
|
% if instance_deletable and master.has_perm('delete_row'):
|
|
<once-button tag="a" href="${action_url('delete', instance)}"
|
|
type="is-danger"
|
|
icon-left="trash"
|
|
text="Delete This">
|
|
</once-button>
|
|
% endif
|
|
% endif
|
|
% elif master and master.editing:
|
|
% if master.viewable and master.has_perm('view'):
|
|
<once-button tag="a" href="${action_url('view', instance)}"
|
|
icon-left="eye"
|
|
text="View This">
|
|
</once-button>
|
|
% endif
|
|
% if master.deletable and instance_deletable and master.has_perm('delete'):
|
|
<once-button tag="a" href="${action_url('delete', instance)}"
|
|
type="is-danger"
|
|
icon-left="trash"
|
|
text="Delete This">
|
|
</once-button>
|
|
% endif
|
|
% elif master and master.deleting:
|
|
% if master.viewable and master.has_perm('view'):
|
|
<once-button tag="a" href="${action_url('view', instance)}"
|
|
icon-left="eye"
|
|
text="View This">
|
|
</once-button>
|
|
% endif
|
|
% if master.editable and instance_editable and master.has_perm('edit'):
|
|
<once-button tag="a" href="${action_url('edit', instance)}"
|
|
icon-left="edit"
|
|
text="Edit This">
|
|
</once-button>
|
|
% endif
|
|
% endif
|
|
</%def>
|
|
|
|
<%def name="render_prevnext_header_buttons()">
|
|
% if show_prev_next is not Undefined and show_prev_next:
|
|
% if prev_url:
|
|
<b-button tag="a" href="${prev_url}"
|
|
icon-pack="fas"
|
|
icon-left="arrow-left">
|
|
Older
|
|
</b-button>
|
|
% else:
|
|
<b-button tag="a" href="#"
|
|
disabled
|
|
icon-pack="fas"
|
|
icon-left="arrow-left">
|
|
Older
|
|
</b-button>
|
|
% endif
|
|
% if next_url:
|
|
<b-button tag="a" href="${next_url}"
|
|
icon-pack="fas"
|
|
icon-left="arrow-right">
|
|
Newer
|
|
</b-button>
|
|
% else:
|
|
<b-button tag="a" href="#"
|
|
disabled
|
|
icon-pack="fas"
|
|
icon-left="arrow-right">
|
|
Newer
|
|
</b-button>
|
|
% endif
|
|
% endif
|
|
</%def>
|
|
|
|
<%def name="declare_whole_page_vars()">
|
|
## ${multi_file_upload.declare_vars()}
|
|
|
|
<script>
|
|
|
|
const WholePage = {
|
|
template: '#whole-page-template',
|
|
mixins: [SimpleRequestMixin],
|
|
|
|
mounted() {
|
|
window.addEventListener('keydown', this.globalKey)
|
|
for (let hook of this.mountedHooks) {
|
|
hook(this)
|
|
}
|
|
},
|
|
beforeDestroy() {
|
|
window.removeEventListener('keydown', this.globalKey)
|
|
},
|
|
|
|
methods: {
|
|
|
|
changeContentTitle(newTitle) {
|
|
this.contentTitleHTML = newTitle
|
|
},
|
|
|
|
% if expose_db_picker is not Undefined and expose_db_picker:
|
|
changeDB() {
|
|
this.$refs.dbPickerForm.submit()
|
|
},
|
|
% endif
|
|
|
|
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
|
|
changeTheme() {
|
|
this.$refs.themePickerForm.submit()
|
|
},
|
|
% endif
|
|
|
|
globalKey(event) {
|
|
|
|
// Ctrl+8 opens global search
|
|
if (event.target.tagName == 'BODY') {
|
|
if (event.ctrlKey && event.key == '8') {
|
|
this.globalSearchInit()
|
|
}
|
|
}
|
|
},
|
|
|
|
globalSearchInit() {
|
|
this.$refs.menuSearch.searchInit()
|
|
},
|
|
|
|
toggleNestedMenu(hash) {
|
|
const key = 'menu_' + hash + '_shown'
|
|
this[key] = !this[key]
|
|
},
|
|
},
|
|
}
|
|
|
|
const WholePageData = {
|
|
contentTitleHTML: ${json.dumps(capture(self.content_title))|n},
|
|
globalSearchData: ${json.dumps(global_search_data)|n},
|
|
mountedHooks: [],
|
|
|
|
% if expose_theme_picker and request.has_perm('common.change_app_theme'):
|
|
globalTheme: ${json.dumps(theme)|n},
|
|
% endif
|
|
|
|
% if can_edit_help:
|
|
configureFieldsHelp: false,
|
|
% endif
|
|
}
|
|
|
|
## declare nested menu visibility toggle flags
|
|
% for topitem in menus:
|
|
% if topitem['is_menu']:
|
|
% for item in topitem['items']:
|
|
% if item['is_menu']:
|
|
WholePageData.menu_${id(item)}_shown = false
|
|
% endif
|
|
% endfor
|
|
% endif
|
|
% endfor
|
|
|
|
</script>
|
|
</%def>
|
|
|
|
<%def name="modify_whole_page_vars()"></%def>
|
|
|
|
## TODO: do we really need this?
|
|
## <%def name="finalize_whole_page_vars()"></%def>
|
|
|
|
<%def name="make_whole_page_component()">
|
|
${self.render_whole_page_template()}
|
|
${self.declare_whole_page_vars()}
|
|
${self.modify_whole_page_vars()}
|
|
## ${self.finalize_whole_page_vars()}
|
|
|
|
${page_help.make_component()}
|
|
## ${multi_file_upload.make_component()}
|
|
|
|
<script>
|
|
WholePage.data = () => { return WholePageData }
|
|
</script>
|
|
<% request.register_component('whole-page', 'WholePage') %>
|
|
</%def>
|
|
|
|
<%def name="make_whole_page_app()">
|
|
<script type="module">
|
|
import {createApp} from 'vue'
|
|
import {Oruga} from '@oruga-ui/oruga-next'
|
|
import {bulmaConfig} from '@oruga-ui/theme-bulma'
|
|
import { library } from "@fortawesome/fontawesome-svg-core"
|
|
import { fas } from "@fortawesome/free-solid-svg-icons"
|
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
|
|
library.add(fas)
|
|
|
|
const app = createApp()
|
|
app.component('vue-fontawesome', FontAwesomeIcon)
|
|
|
|
% if hasattr(request, '_tailbone_registered_components'):
|
|
% for tagname, classname in request._tailbone_registered_components.items():
|
|
app.component('${tagname}', ${classname})
|
|
% endfor
|
|
% endif
|
|
|
|
app.use(Oruga, {
|
|
...bulmaConfig,
|
|
iconComponent: 'vue-fontawesome',
|
|
iconPack: 'fas',
|
|
})
|
|
|
|
app.use(HttpPlugin)
|
|
app.use(BuefyPlugin)
|
|
|
|
app.mount('#app')
|
|
</script>
|
|
</%def>
|
|
|