Files
StarPilot/openpilot/starpilot/galaxy/web/js/settings.js
T

469 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { connectionError } from "./polling.js"
import { reactive, nextTick } from "../vendor/vue/vue.esm-browser.js"
import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js"
import { OnroadLayoutPage } from "./onroad-layout.js"
import { FavoritesPage } from "./favorites.js"
import { SoundPacks } from "./sound-packs.js"
import { CloudProviderPage } from "./cloud-provider.js"
import { GxNotice } from "./notice.js"
import { route } from "./route-state.js"
const post = (fetcher, path, body, signal) => fetcher(path, {
method: "POST", credentials: "same-origin", cache: "no-store", signal,
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
})
export const SETTINGS_SECTIONS = Object.freeze([
{ id: "lateral", label: "Lateral (Steering)", icon: "bi-arrows-move", pages: ["aol", "lane_change", "torque", "lane"] },
{ id: "longitudinal", label: "Longitudinal (Speed & Following)", icon: "bi-speedometer2", pages: ["profiles", "conditional", "curve", "slc", "aggressive", "standard", "relaxed", "traffic"] },
{ id: "wheel", label: "Wheel Controls", icon: "bi-controller", pages: ["wheel"] },
{ id: "visual", label: "Visual (Display & UI)", icon: "bi-eye", pages: ["appearance", "ui_layout", "favorites", "pip"] },
{ id: "sounds", label: "Sounds & Alerts", icon: "bi-volume-up", pages: ["sounds"] },
{ id: "device", label: "Device & Data", icon: "bi-hdd", pages: ["display", "data"] },
{ id: "localization", label: "Localization", icon: "bi-globe", pages: ["localization"] },
{ id: "developer", label: "Developer", icon: "bi-code-slash", pages: ["developer"] },
])
// Crop numeric rows and the inline editor row are controlled from the dedicated
// Blind Spot Camera page; they stay writable through the settings API for the
// widget/VASM editor but must not surface in the generic toggles list.
export const isPipCropRow = (row) => ["Visual camera crop editor", "Camera crop size", "Restore default camera crop"].includes(row.label) ||
/^Vehicle (left|right) crop/.test(row.label || "")
const SECTION_LINKS = {
device: [{ label: "Display", page: "display", value: "Adjust screen brightness and display timing" }, { label: "Data Uploads", page: "data", value: "Choose mobile-data uploads and usage-statistics sharing" }],
visual: [{ label: "Driving Screen Widgets, Colors & Layout", page: "ui_layout" }, { label: "Quick Select", page: "favorites" }, { label: "Blind Spot Camera and Preview", page: "pip" }],
}
const activeSettings = new Set()
export const settingsBusy = state => ["updating", "saving"].includes(state.status) || !!state.pending || !!state.reviewing
export class SettingsFeed {
constructor({ publish, unauthorized = () => { }, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.pollTimer = null
this.polling = false
this.saving = false
this.page = "hub"
this.data = null
this.pending = null
this.error = ""
}
stop() {
activeSettings.delete(this)
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
this.polling = false
this.saving = false
this.data = null
this.pending = null
this.error = ""
this.publish({ status: "idle", data: null, pending: null, error: "" })
}
start(page = "hub") {
this.stop()
this.active = true
activeSettings.add(this)
this.page = page
return this.load(page)
}
async run(operation, { saving = false, background = false } = {}) {
if (!this.active) return null
const generation = ++this.generation
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const request = new AbortController()
this.request = request
this.saving = saving
this.polling = background
let failed = false
this.timer = this.later(() => {
if (!this.active || generation !== this.generation || this.request !== request) return
request.abort()
this.generation++
this.request = this.timer = null
this.saving = false
this.polling = false
if (!background) this.data = this.pending = null
else this.expireMonitorStatus()
this.error = saving ? "Saving timed out. Checking saved values before another change." : "Settings could not connect. Reconnecting automatically…"
this.publish({ status: background && this.data ? "ready" : "unavailable", data: this.data, pending: this.pending, error: this.error })
this.scheduleParkedRefresh()
}, 4000)
try {
const response = await operation(request.signal)
if (!this.active || generation !== this.generation || request.signal.aborted) return null
const accessCode = response.status === 503 ? (await response.clone().json().catch(() => null))?.code : null
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(accessCode)) {
this.stop()
this.unauthorized()
return null
}
if (response.status === 409) {
const error = new Error("Saved settings changed. Refresh this page.")
error.stale = true
throw error
}
if (!response.ok) throw new Error(response.status === 503 ? "The device could not read these preferences. Reconnecting automatically…" : "The device could not complete the settings request. Check your connection and try again.")
const data = await response.json().catch(() => { throw new Error("Galaxy could not load settings. Refresh to reconnect.") })
if (!this.active || generation !== this.generation || request.signal.aborted) return null
return data
} catch (error) {
failed = true
if (this.active && generation === this.generation && !request.signal.aborted) {
this.pending = null
this.expireMonitorStatus()
if (error?.stale) this.data = null
this.publish({
status: this.data ? "ready" : "unavailable", data: this.data, pending: null,
error: this.error = connectionError(error)
})
}
return null
} finally {
if (this.request === request) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
this.saving = false
this.polling = false
if (failed) this.scheduleParkedRefresh()
}
}
}
expireMonitorStatus() {
if (this.data?.page === "sentry") this.data = { ...this.data, subtitle: "Motion monitor status is unavailable. Reconnecting automatically…" }
}
scheduleParkedRefresh() {
if (!this.active || this.pending || this.request || this.saving || this.pollTimer !== null) return
const generation = this.generation
this.pollTimer = this.later(() => {
this.pollTimer = null
if (this.active && this.generation === generation && !this.pending && !this.request && !this.saving)
this.load(this.page, { keepData: true, quiet: true })
}, this.error ? 3000 : 1500)
}
async load(page = this.page, { keepData = page === this.page && this.data !== null, quiet = keepData } = {}) {
if (!this.active || this.saving || (quiet && (this.pending || this.request))) return
this.page = page
this.pending = null
if (!keepData) this.data = null
if (!quiet) this.publish({ status: keepData ? "saving" : this.error ? "unavailable" : "loading", data: this.data, pending: null, error: this.error })
const operation = this.run((signal) => this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`,
{ credentials: "same-origin", cache: "no-store", signal }), { background: quiet })
const attempt = this.generation
const data = await operation
if (data && this.active && this.generation === attempt && this.page === page) {
this.data = data
this.error = ""
this.publish({ status: "ready", data, pending: null, error: "" })
}
this.scheduleParkedRefresh()
}
async preview(row, direction, draft = null) {
if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return
this.publish({ status: "updating", data: this.data, pending: null, error: "" })
const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview",
{ view: this.data.view, row, direction, ...(draft === null ? {} : { draft }) }, signal))
if (data) {
this.pending = data
this.publish({ status: "ready", data: this.data, pending: data, error: "" })
}
}
async resetDefault(row) {
if (!this.active || !this.data?.view || !this.data.rows?.[row]?.resetAvailable ||
this.pending || (this.request && !this.polling)) return
this.publish({ status: "updating", data: this.data, pending: null, error: "" })
const data = await this.run((signal) => post(this.fetcher, "./api/settings/reset-default",
{ view: this.data.view, row }, signal))
if (data) {
this.pending = data
await this.confirm()
}
}
async previewValue(row, value) {
if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return
this.publish({ status: "updating", data: this.data, pending: null, error: "" })
const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview",
{ view: this.data.view, row, value }, signal))
if (data) {
this.pending = data
await this.confirm()
}
}
cancel() {
this.pending = null
if (this.active) this.publish({ status: "ready", data: this.data, pending: null, error: "" })
this.scheduleParkedRefresh()
}
async confirm() {
if (!this.active || !this.pending?.intent) return
const intent = this.pending.intent
this.pending = null
this.publish({ status: "saving", data: this.data, pending: null, error: "" })
const page = this.page
const operation = this.run((signal) => post(this.fetcher, "./api/settings/confirm",
{ intent, confirmed: true, page }, signal), { saving: true })
const attempt = this.generation
const result = await operation
if (result?.saved === true) {
for (const feed of activeSettings) {
if (feed !== this && !feed.pending && !feed.saving) feed.load(feed.page, { keepData: true, quiet: true })
}
}
if (this.active && this.generation === attempt) {
if (result?.saved === true && result.page?.page === page && result.page?.view) {
this.data = result.page
this.error = ""
this.publish({ status: "ready", data: this.data, pending: null, error: "" })
this.scheduleParkedRefresh()
return
}
const reload = this.load(page, { keepData: true, quiet: false })
const refreshed = this.generation
await reload
if (result?.saved !== true && this.active && this.generation === refreshed && this.data) this.publish({
status: "ready", data: this.data, pending: null,
error: "Save could not be confirmed. Current saved values are shown; review them before trying again."
})
}
}
}
export const SettingsPage = {
emits: ["nested"],
components: { GxDialog, GxState, GalaxySettingRow, OnroadLayoutPage, FavoritesPage, SoundPacks, CloudProviderPage, GxNotice },
props: {
mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
initialPage: { type: String, default: "hub" }, title: { type: String, default: "Toggles" },
initialSection: { type: String, default: null }, returnTo: { type: Function, default: null }
},
setup(props) {
const navigation = route.view
const navigationKey = `${props.initialPage}:${props.initialSection || ""}`
const saved = navigation.settings?.key === navigationKey ? navigation.settings : null
const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 })
if (saved) Object.assign(state, { section: saved.section, query: saved.query, searchOpen: saved.searchOpen, parents: saved.parents.slice(), developerOpen: saved.developerOpen })
const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed, sections: SETTINGS_SECTIONS, navigation, navigationKey, restoredPage: saved?.page }
},
computed: {
busy() { return settingsBusy(this.state) },
activeSection() {
if (this.state.developerOpen) return this.sections.at(-1)
const page = (this.state.data?.page || "hub").split("/")[0]
return this.sections.find((section) => page === "hub" ? section.id === this.state.section : section.pages.includes(page)) || this.sections[0]
},
atSectionRoot() {
if (this.state.developerOpen || (!this.state.data && this.initialPage === "hub" && !this.state.parents?.length)) return true
return this.state.data?.page === "hub" ||
(this.activeSection.pages.length === 1 && this.state.data?.page === this.activeSection.pages[0])
},
visibleRows() {
const needle = this.state.query.trim().toLocaleLowerCase()
const rows = this.state.data?.rows || []
const inHub = this.initialPage === "hub" && this.state.data?.page === "hub"
const section = this.activeSection
const entries = inHub && SECTION_LINKS[section.id]
? SECTION_LINKS[section.id].map((row, index) => ({ row: { ...row, available: true, action: false, value: row.value || "Customize " + row.label.toLocaleLowerCase() }, index }))
: rows.map((row, index) => ({ row, index })).filter(({ row }) => !inHub || section.pages.includes(row.page))
if (inHub && section.id === "longitudinal") {
entries.sort((a, b) => section.pages.indexOf(a.row.page) - section.pages.indexOf(b.row.page))
}
return entries.filter(({ row }) => !isPipCropRow(row)).filter(({ row, index }) => {
const previous = rows[index - 1]
return !(["sounds", "display"].includes(this.state.data?.page) && row.repairValue === "Auto" &&
previous?.choices?.includes("Auto") && settingControl(previous) === "slider" && row.label === `Use Auto ${previous.label}`)
}).filter(({ row }) =>
!needle || `${row.label} ${row.value} ${row.reason}`.toLocaleLowerCase().includes(needle))
},
},
watch: {
"state.pending"() { this.flushSoundChoices() },
"state.status"(status, previous) {
if (previous === "saving" && status === "ready") this.state.soundChoicesDirty = false
else this.flushSoundChoices()
},
},
async mounted() {
if (this.mode !== "local") return
await this.feed.start(this.restoredPage || (this.state.developerOpen ? "developer" : this.initialPage))
await nextTick()
if (this.feed.active && this.navigation.scroll) window.scrollTo(this.navigation.scroll.x, this.navigation.scroll.y)
},
beforeUnmount() {
const { data, section, query, searchOpen, parents, developerOpen } = this.state
this.navigation.settings = {
key: this.navigationKey, page: data?.page || this.restoredPage || this.initialPage, section, query, searchOpen, parents: parents.slice(), developerOpen,
}
this.navigation.searchPage = this.initialPage === "hub" ? "" : this.initialPage
this.feed.stop()
},
methods: {
open(page) {
if (this.busy) return
this.$emit?.("nested")
this.state.query = ""
this.state.panelDirection = 1
if (page === "ui_layout" || page === "appearance") {
this.feed.stop()
this.state.layoutPanel = page === "appearance" ? "display" : "widgets"
this.state.layoutOpen = true
}
else if (page === "favorites") { this.feed.stop(); this.state.favoritesOpen = true }
else {
if (this.state.data?.page && this.state.data.page !== page) (this.state.parents ||= []).push(this.state.data.page)
this.feed.load(page)
}
},
togglePageSearch() {
this.state.searchOpen = !this.state.searchOpen
if (this.state.searchOpen) this.$nextTick(() => this.$refs.pageSearch?.focus())
else this.state.query = ""
},
async openCropEditor() { (await import("./router.js")).navigate("/cameras/pip") },
// The embedded editor switches targets through the routed editor, which keeps the leave guard.
async openLayoutTarget(target) { (await import("./router.js")).navigate(target === "projection" ? "/theme_maker/starport" : "/theme_maker") },
rowKey(row, index) { return `${this.state.data.page}:${row.key || row.page || index}` },
closeLayout() { this.state.layoutOpen = false; this.state.section = "visual"; this.feed.start(this.initialPage) },
closeFavorites() { this.state.favoritesOpen = false; this.state.section = "visual"; this.feed.start("hub") },
selectSection(section) {
if (this.busy) return
if (this.sections) {
const from = this.sections.findIndex((item) => item.id === this.state.section)
const to = this.sections.findIndex((item) => item.id === section.id)
if (from !== -1 && to !== -1) this.state.panelDirection = to < from ? -1 : 1
}
this.state.parents = []
this.state.section = section.id
this.state.developerOpen = section.id === "developer"
if (!this.feed.active) { this.feed.start(section.pages.length === 1 ? section.pages[0] : "hub"); return }
this.state.query = ""
const page = section.pages.length === 1 ? section.pages[0] : "hub"
if (this.state.data?.page !== page) this.feed.load(page, { keepData: false, quiet: false })
},
requestRouteLeave(proceed) {
if (this.busy || this.$refs.favoritesEditor?.busy || this.$refs.cloudProvider?.busy) return
if (this.state.layoutOpen) this.$refs.layoutEditor.requestLeave(proceed)
else proceed()
},
navigateBack() {
if (this.state.pending) { this.feed.cancel(); return true }
if (this.busy) return true
if (this.state.layoutOpen) { this.$refs.layoutEditor.requestLeave("back"); return true }
if (this.state.favoritesOpen) { if (!this.$refs.favoritesEditor?.busy) this.closeFavorites(); return true }
if (this.state.developerOpen) return false
const nested = this.initialPage === "hub" ? !this.atSectionRoot : this.state.data?.page !== this.initialPage
if (!nested) return false
this.back()
return true
},
back() {
if (this.state.developerOpen) return
this.state.panelDirection = -1
const page = this.state.data?.page || this.initialPage
this.state.section = this.activeSection?.id || this.state.section
this.state.query = ""
if (page === this.initialPage && this.returnTo) { this.returnTo(); return }
const parent = this.state.parents?.pop()
if (parent) { this.feed.load(parent); return }
if (this.initialPage !== "hub") { this.feed.load(this.initialPage); return }
this.feed.load(page.includes("/") ? page.split("/")[0] :
["aggressive", "standard", "relaxed"].includes(page) ? "profiles" : "hub")
},
refreshSoundChoices() {
this.state.soundChoicesDirty = true
this.flushSoundChoices()
},
flushSoundChoices() {
if (!this.state.soundChoicesDirty || this.state.data?.page !== "sounds" || this.feed.saving || this.state.pending) return
this.state.soundChoicesDirty = false
this.feed.load("sounds", { keepData: true })
},
},
template: `
<OnroadLayoutPage ref="layoutEditor" v-if="state.layoutOpen" :mode="mode" :unauthorized="unauthorized" :initial-panel="state.layoutPanel" @target="openLayoutTarget" @close="closeLayout" />
<FavoritesPage ref="favoritesEditor" v-else-if="state.favoritesOpen" :mode="mode" :unauthorized="unauthorized" @close="closeFavorites" />
<section v-else class="gx-settings" :aria-label="title" :inert="busy" :aria-busy="busy || undefined">
<div v-if="title && (initialPage !== 'hub' || atSectionRoot)" class="gx-settings__header gx-page-header"><h2>{{ title }}</h2></div>
<div v-if="mode === 'local' && initialPage === 'hub' && atSectionRoot" class="gx-settings-tabs" aria-label="Settings sections">
<button v-for="section in sections" :key="section.id" type="button" class="gx-chip" :aria-pressed="section.id === activeSection.id" @click="selectSection(section)">{{ section.label }}</button>
</div>
<p class="gx-page-description" v-if="state.developerOpen">Choose developer driving options and cloud services.</p>
<p class="gx-page-description" v-else-if="initialPage === 'pip'">Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.</p>
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'sounds'">Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.</p>
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'display'">Adjust brightness and screen timing when custom display settings are on.</p>
<p class="gx-page-description" v-else-if="initialPage === 'lane_change'">Configure lane-change assistance, signal timing, steering pace and following gap.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'conditional' || state.data?.page.startsWith('conditional/')">Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'profiles'">Adjust acceleration, braking and following for each driving personality.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'traffic'">Tune low-speed following and jerk, blending toward Relaxed at higher speeds.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'aol'">Keep steering assistance active independently of cruise control.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'torque'">Tune how StarPilot applies steering torque.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'lane'">Choose lane-centering behavior and alerts.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'curve'">Set how the vehicle slows for curves ahead.</p>
<div v-else-if="state.data?.page === 'slc'"><p class="gx-page-description">Control how speed limits change your set speed.</p>
<a class="gx-btn gx-btn--tonal" href="https://wiki.firestar.link/exclusive/speed-limit-controller/#vision-speed-limits" target="_blank" rel="noopener noreferrer"><i class="bi bi-book" aria-hidden="true"></i> Vision speed limits guide</a></div>
<p class="gx-page-description" v-else-if="state.data?.page === 'wheel'">Assign what the steering-wheel controls do.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'data'">Choose what uploads when the vehicle uses mobile data.</p>
<p class="gx-page-description" v-else-if="['aggressive','standard','relaxed'].includes(state.data?.page)">Adjust acceleration, braking and following for this driving style.</p>
<p class="gx-page-description" v-else-if="initialPage === 'sentry'">{{ state.data?.subtitle || "Checking motion monitor…" }}</p>
<GxState v-if="mode !== 'local'">Local settings are unavailable in preview.</GxState>
<template v-else>
<GxNotice v-if="state.data && state.data.vehicleIdentified === false" tone="info">Vehicle not identified yet. Turn on the car and wait for fingerprinting; vehicle-specific settings will then appear here.</GxNotice>
<CloudProviderPage ref="cloudProvider" v-if="state.developerOpen" :mode="mode" :unauthorized="unauthorized" />
<GxState v-if="state.status === 'loading'" loading>Reading your device’s saved preferences…</GxState>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read these preferences. Reconnecting automatically…</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}
</GxNotice>
<div v-if="!state.developerOpen || state.data?.page === 'developer'" class="gx-panel-stage" :style="{'--panel-direction': state.panelDirection}">
<Transition name="gx-panel">
<div v-if="state.data" :key="state.data.page + activeSection.id" class="gx-settings__body">
<div v-if="state.data.page === 'pip'" class="gx-settings__actions gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" @click="openCropEditor"><i class="bi bi-crop" aria-hidden="true"></i> Crop editor</button>
</div>
<section class="gx-card gx-settings__section">
<div class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
<button v-if="state.data.page !== 'hub' && state.data.rows.length" type="button" class="gx-icon-btn" :aria-label="state.searchOpen ? 'Close page search' : 'Search within this page'" :aria-pressed="state.searchOpen" @click="togglePageSearch"><i class="bi" :class="state.searchOpen ? 'bi-x-lg' : 'bi-search'" aria-hidden="true"></i></button>
</div>
<div v-if="state.searchOpen" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
<GalaxySettingRow v-for="{ row, index } in visibleRows" :key="rowKey(row, index)" :row="row" :index="index"
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)"
@open="open" @review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
<div v-if="!visibleRows.length" class="gx-empty">No matching settings.</div>
</section>
<SoundPacks v-if="state.data.page === 'sounds'" :unauthorized="unauthorized" :busy="busy" @installed="refreshSoundChoices" />
</div>
</Transition>
</div>
<GxDialog v-if="state.pending" labelledby="gx-settings-confirm-title" @close="feed.cancel()"><h3 id="gx-settings-confirm-title">Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></GxDialog>
</template>
</section>`,
}