pwa first pass
This commit is contained in:
parent
b96a85ee11
commit
a9e63338ed
|
@ -29,6 +29,11 @@
|
||||||
url={$url("./embed")}
|
url={$url("./embed")}
|
||||||
active={$isActive("./embed")}
|
active={$isActive("./embed")}
|
||||||
/>
|
/>
|
||||||
|
<SideNavItem
|
||||||
|
text="PWA"
|
||||||
|
url={$url("./pwa")}
|
||||||
|
active={$isActive("./pwa")}
|
||||||
|
/>
|
||||||
<SideNavItem
|
<SideNavItem
|
||||||
text="Export/Import"
|
text="Export/Import"
|
||||||
url={$url("./exportImport")}
|
url={$url("./exportImport")}
|
||||||
|
|
|
@ -0,0 +1,430 @@
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Layout,
|
||||||
|
Divider,
|
||||||
|
Heading,
|
||||||
|
Body,
|
||||||
|
Input,
|
||||||
|
TextArea,
|
||||||
|
ColorPicker,
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
|
File,
|
||||||
|
notifications,
|
||||||
|
Select,
|
||||||
|
} from "@budibase/bbui"
|
||||||
|
import { appStore } from "@/stores/builder"
|
||||||
|
import { API } from "@/api"
|
||||||
|
|
||||||
|
// Only allow PNG files for better PWA compatibility
|
||||||
|
const imageExtensions = [".png"]
|
||||||
|
|
||||||
|
let pwaConfig = $appStore.pwa || {
|
||||||
|
name: "",
|
||||||
|
short_name: "",
|
||||||
|
description: "",
|
||||||
|
icons: [],
|
||||||
|
background_color: "#FFFFFF",
|
||||||
|
theme_color: "#FFFFFF",
|
||||||
|
display: "standalone",
|
||||||
|
start_url: "",
|
||||||
|
scope: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
let saving = false
|
||||||
|
let iconFile = null
|
||||||
|
let iconPreview = null
|
||||||
|
|
||||||
|
// Display mode options
|
||||||
|
const displayOptions = [
|
||||||
|
{ label: "Standalone", value: "standalone" },
|
||||||
|
{ label: "Fullscreen", value: "fullscreen" },
|
||||||
|
{ label: "Minimal UI", value: "minimal-ui" },
|
||||||
|
{ label: "Browser", value: "browser" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Get existing icon if available
|
||||||
|
$: icon =
|
||||||
|
pwaConfig.icons && pwaConfig.icons.length > 0
|
||||||
|
? { url: pwaConfig.icons[0].src, type: "image", name: "PWA Icon" }
|
||||||
|
: null
|
||||||
|
|
||||||
|
const previewUrl = async localFile => {
|
||||||
|
if (!localFile) {
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let reader = new FileReader()
|
||||||
|
try {
|
||||||
|
reader.onload = e => {
|
||||||
|
resolve({
|
||||||
|
result: e.target.result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(localFile)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$: previewUrl(iconFile).then(response => {
|
||||||
|
if (response) {
|
||||||
|
iconPreview = response.result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function uploadIcon(file) {
|
||||||
|
let response = {}
|
||||||
|
try {
|
||||||
|
let data = new FormData()
|
||||||
|
data.append("file", file)
|
||||||
|
response = await API.uploadBuilderAttachment(data)
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("Error uploading icon")
|
||||||
|
console.error("Error uploading icon:", error)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFiles() {
|
||||||
|
if (iconFile) {
|
||||||
|
const iconResp = await uploadIcon(iconFile)
|
||||||
|
if (iconResp[0]?.url) {
|
||||||
|
// Get the full absolute URL
|
||||||
|
let iconUrl = iconResp[0].url
|
||||||
|
// Ensure the URL is absolute
|
||||||
|
if (iconUrl.startsWith("/")) {
|
||||||
|
iconUrl = window.location.origin + iconUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the PWA config with the new icon URL
|
||||||
|
pwaConfig = {
|
||||||
|
...pwaConfig,
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: iconUrl,
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHexFormat(color) {
|
||||||
|
if (!color) return "#FFFFFF"
|
||||||
|
|
||||||
|
if (color.startsWith("#")) return color
|
||||||
|
|
||||||
|
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
|
||||||
|
if (rgbMatch) {
|
||||||
|
const r = parseInt(rgbMatch[1]).toString(16).padStart(2, "0")
|
||||||
|
const g = parseInt(rgbMatch[2]).toString(16).padStart(2, "0")
|
||||||
|
const b = parseInt(rgbMatch[3]).toString(16).padStart(2, "0")
|
||||||
|
return `#${r}${g}${b}`.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/)
|
||||||
|
if (rgbaMatch) {
|
||||||
|
const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, "0")
|
||||||
|
const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, "0")
|
||||||
|
const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, "0")
|
||||||
|
return `#${r}${g}${b}`.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return "#FFFFFF"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCssVariableValue(cssVar) {
|
||||||
|
if (!cssVar || !cssVar.startsWith("var(--")) {
|
||||||
|
return ensureHexFormat(cssVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
const spectrumColors = {
|
||||||
|
"var(--spectrum-global-color-gray-50)": "#FFFFFF",
|
||||||
|
"var(--spectrum-global-color-gray-75)": "#FAFAFA",
|
||||||
|
"var(--spectrum-global-color-gray-100)": "#F5F5F5",
|
||||||
|
"var(--spectrum-global-color-gray-200)": "#EAEAEA",
|
||||||
|
"var(--spectrum-global-color-gray-300)": "#E1E1E1",
|
||||||
|
"var(--spectrum-global-color-gray-400)": "#CACACA",
|
||||||
|
"var(--spectrum-global-color-gray-500)": "#B3B3B3",
|
||||||
|
"var(--spectrum-global-color-gray-600)": "#8E8E8E",
|
||||||
|
"var(--spectrum-global-color-gray-700)": "#6E6E6E",
|
||||||
|
"var(--spectrum-global-color-gray-800)": "#4B4B4B",
|
||||||
|
"var(--spectrum-global-color-gray-900)": "#2C2C2C",
|
||||||
|
"var(--spectrum-global-color-blue-400)": "#2680EB",
|
||||||
|
"var(--spectrum-global-color-blue-500)": "#1473E6",
|
||||||
|
"var(--spectrum-global-color-blue-600)": "#0D66D0",
|
||||||
|
"var(--spectrum-global-color-blue-700)": "#095ABA",
|
||||||
|
"var(--spectrum-global-color-red-400)": "#E34850",
|
||||||
|
"var(--spectrum-global-color-red-500)": "#D7373F",
|
||||||
|
"var(--spectrum-global-color-red-600)": "#C9252D",
|
||||||
|
"var(--spectrum-global-color-red-700)": "#BB121A",
|
||||||
|
"var(--spectrum-global-color-green-400)": "#2D9D78",
|
||||||
|
"var(--spectrum-global-color-green-500)": "#268E6C",
|
||||||
|
"var(--spectrum-global-color-green-600)": "#12805C",
|
||||||
|
"var(--spectrum-global-color-green-700)": "#107154",
|
||||||
|
"var(--spectrum-global-color-orange-400)": "#E68619",
|
||||||
|
"var(--spectrum-global-color-orange-500)": "#DA7B11",
|
||||||
|
"var(--spectrum-global-color-orange-600)": "#CB6F10",
|
||||||
|
"var(--spectrum-global-color-orange-700)": "#BD640D",
|
||||||
|
"var(--spectrum-global-color-yellow-400)": "#DFBF00",
|
||||||
|
"var(--spectrum-global-color-yellow-500)": "#D2B200",
|
||||||
|
"var(--spectrum-global-color-yellow-600)": "#C4A600",
|
||||||
|
"var(--spectrum-global-color-yellow-700)": "#B79900",
|
||||||
|
"var(--spectrum-global-color-seafoam-400)": "#1B959A",
|
||||||
|
"var(--spectrum-global-color-seafoam-500)": "#16878C",
|
||||||
|
"var(--spectrum-global-color-seafoam-600)": "#0F797D",
|
||||||
|
"var(--spectrum-global-color-seafoam-700)": "#096C6F",
|
||||||
|
"var(--spectrum-global-color-indigo-400)": "#6767EC",
|
||||||
|
"var(--spectrum-global-color-indigo-500)": "#5C5CE0",
|
||||||
|
"var(--spectrum-global-color-indigo-600)": "#5151D3",
|
||||||
|
"var(--spectrum-global-color-indigo-700)": "#4646C6",
|
||||||
|
"var(--spectrum-global-color-magenta-400)": "#D83790",
|
||||||
|
"var(--spectrum-global-color-magenta-500)": "#CE2783",
|
||||||
|
"var(--spectrum-global-color-magenta-600)": "#BC1C74",
|
||||||
|
"var(--spectrum-global-color-magenta-700)": "#AE0E66",
|
||||||
|
"var(--spectrum-global-color-static-white)": "#FFFFFF",
|
||||||
|
"var(--spectrum-global-color-static-black)": "#000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (spectrumColors[cssVar]) {
|
||||||
|
return spectrumColors[cssVar]
|
||||||
|
}
|
||||||
|
|
||||||
|
const varName = cssVar.match(/var\((.*?)\)/)[1]
|
||||||
|
const computedValue = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue(varName)
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (computedValue) {
|
||||||
|
return ensureHexFormat(computedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "#FFFFFF"
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error converting CSS variable:", error)
|
||||||
|
return "#FFFFFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
saving = true
|
||||||
|
|
||||||
|
await saveFiles()
|
||||||
|
|
||||||
|
const bgColor = getCssVariableValue(pwaConfig.background_color)
|
||||||
|
const themeColor = getCssVariableValue(pwaConfig.theme_color)
|
||||||
|
|
||||||
|
const pwaConfigToSave = {
|
||||||
|
...pwaConfig,
|
||||||
|
background_color: bgColor,
|
||||||
|
theme_color: themeColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
await API.saveAppMetadata($appStore.appId, { pwa: pwaConfigToSave })
|
||||||
|
|
||||||
|
appStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
pwa: pwaConfigToSave,
|
||||||
|
}))
|
||||||
|
|
||||||
|
notifications.success("PWA settings saved successfully")
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("Error saving PWA settings")
|
||||||
|
console.error("Error saving PWA settings:", error)
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Layout noPadding>
|
||||||
|
<Layout gap="XS" noPadding>
|
||||||
|
<Heading>Progressive Web App</Heading>
|
||||||
|
<Body>
|
||||||
|
Transform your app into an installable, app-like experience with a
|
||||||
|
Progressive Web App (PWA). Developers can configure app details, visuals,
|
||||||
|
and notifications to create a branded, professional experience for their
|
||||||
|
users.
|
||||||
|
</Body>
|
||||||
|
</Layout>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
<div class="fields">
|
||||||
|
<!-- App details section -->
|
||||||
|
<div class="section">
|
||||||
|
<Heading size="S">App details</Heading>
|
||||||
|
<Body size="S">
|
||||||
|
Define the identity of your app, including its name, description, and
|
||||||
|
how it will appear to users when installed.
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">App name</Label>
|
||||||
|
<Input
|
||||||
|
bind:value={pwaConfig.name}
|
||||||
|
placeholder="Full name of your app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">Short name</Label>
|
||||||
|
<Input
|
||||||
|
bind:value={pwaConfig.short_name}
|
||||||
|
placeholder="Short name for app icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">Description</Label>
|
||||||
|
<Input
|
||||||
|
bind:value={pwaConfig.description}
|
||||||
|
placeholder="Describe your app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<!-- Appearance section -->
|
||||||
|
<div class="section">
|
||||||
|
<Heading size="S">Appearance</Heading>
|
||||||
|
<Body size="S">
|
||||||
|
Make your app visually appealing with a custom icon and theme. These
|
||||||
|
settings control how your app appears on splash screens and device
|
||||||
|
interfaces.
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">App Icon</Label>
|
||||||
|
<div class="icon-upload">
|
||||||
|
<File
|
||||||
|
title="Upload 192x192 PNG"
|
||||||
|
handleFileTooLarge={() => {
|
||||||
|
notifications.warn("File too large. 20mb limit")
|
||||||
|
}}
|
||||||
|
extensions={imageExtensions}
|
||||||
|
previewUrl={iconPreview || icon?.url}
|
||||||
|
on:change={e => {
|
||||||
|
if (e.detail) {
|
||||||
|
iconFile = e.detail
|
||||||
|
iconPreview = null
|
||||||
|
} else {
|
||||||
|
iconFile = null
|
||||||
|
iconPreview = null
|
||||||
|
pwaConfig = {
|
||||||
|
...pwaConfig,
|
||||||
|
icons: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={iconFile || icon}
|
||||||
|
disabled={saving}
|
||||||
|
allowClear={true}
|
||||||
|
/>
|
||||||
|
<div class="icon-help">
|
||||||
|
<p>Use a 192x192 PNG image</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">Background</Label>
|
||||||
|
<ColorPicker
|
||||||
|
value={pwaConfig.background_color}
|
||||||
|
on:change={e => (pwaConfig.background_color = e.detail)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">Theme</Label>
|
||||||
|
<ColorPicker
|
||||||
|
value={pwaConfig.theme_color}
|
||||||
|
on:change={e => (pwaConfig.theme_color = e.detail)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<!-- Manifest settings section -->
|
||||||
|
<div class="section">
|
||||||
|
<Heading size="S">Manifest settings</Heading>
|
||||||
|
<Body size="S">
|
||||||
|
The manifest settings control how your app behaves once installed.
|
||||||
|
These settings define the app's entry point, navigation boundaries,
|
||||||
|
and how it appears on the user's device. Configuring these fields
|
||||||
|
ensures your app is treated as a native-like application.
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">Start URL</Label>
|
||||||
|
<Input bind:value={pwaConfig.start_url} placeholder="/" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">Display mode</Label>
|
||||||
|
<Select bind:value={pwaConfig.display} options={displayOptions} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label size="L">Scope</Label>
|
||||||
|
<Input bind:value={pwaConfig.scope} placeholder="/" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<Button cta on:click={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
grid-gap: var(--spacing-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 220px;
|
||||||
|
grid-gap: var(--spacing-l);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-upload {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-help {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-help p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -6,6 +6,7 @@ import {
|
||||||
AppIcon,
|
AppIcon,
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
Plugin,
|
Plugin,
|
||||||
|
PWAManifest,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
interface ClientFeatures {
|
interface ClientFeatures {
|
||||||
|
@ -46,6 +47,7 @@ interface AppMetaState {
|
||||||
revertableVersion?: string
|
revertableVersion?: string
|
||||||
upgradableVersion?: string
|
upgradableVersion?: string
|
||||||
icon?: AppIcon
|
icon?: AppIcon
|
||||||
|
pwa?: PWAManifest
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_APP_META_STATE: AppMetaState = {
|
export const INITIAL_APP_META_STATE: AppMetaState = {
|
||||||
|
@ -79,6 +81,14 @@ export const INITIAL_APP_META_STATE: AppMetaState = {
|
||||||
usedPlugins: [],
|
usedPlugins: [],
|
||||||
automations: {},
|
automations: {},
|
||||||
routes: {},
|
routes: {},
|
||||||
|
pwa: {
|
||||||
|
name: "",
|
||||||
|
short_name: "",
|
||||||
|
description: "",
|
||||||
|
icons: [],
|
||||||
|
background_color: "",
|
||||||
|
theme_color: "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppMetaStore extends BudiStore<AppMetaState> {
|
export class AppMetaStore extends BudiStore<AppMetaState> {
|
||||||
|
@ -96,7 +106,6 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
|
||||||
hasLock: boolean
|
hasLock: boolean
|
||||||
}) {
|
}) {
|
||||||
const { application: app, clientLibPath, hasLock } = pkg
|
const { application: app, clientLibPath, hasLock } = pkg
|
||||||
|
|
||||||
this.update(state => ({
|
this.update(state => ({
|
||||||
...state,
|
...state,
|
||||||
name: app.name,
|
name: app.name,
|
||||||
|
@ -118,6 +127,7 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
|
||||||
initialised: true,
|
initialised: true,
|
||||||
automations: app.automations || {},
|
automations: app.automations || {},
|
||||||
hasAppPackage: true,
|
hasAppPackage: true,
|
||||||
|
pwa: app.pwa,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,11 +159,21 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
|
||||||
// Returned from socket
|
// Returned from socket
|
||||||
syncMetadata(metadata: { name: string; url: string; icon?: AppIcon }) {
|
syncMetadata(metadata: { name: string; url: string; icon?: AppIcon }) {
|
||||||
const { name, url, icon } = metadata
|
const { name, url, icon } = metadata
|
||||||
|
console.log(name)
|
||||||
this.update(state => ({
|
this.update(state => ({
|
||||||
...state,
|
...state,
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
icon,
|
icon,
|
||||||
|
pwa: {
|
||||||
|
...state.pwa,
|
||||||
|
name,
|
||||||
|
short_name: state.pwa?.short_name || "",
|
||||||
|
description: state.pwa?.description || "",
|
||||||
|
icons: state.pwa?.icons || [],
|
||||||
|
background_color: state.pwa?.background_color || "",
|
||||||
|
theme_color: state.pwa?.theme_color || "",
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,6 +101,7 @@ export const createBuilderWebsocket = (appId: string) => {
|
||||||
socket.onOther(
|
socket.onOther(
|
||||||
BuilderSocketEvent.AppMetadataChange,
|
BuilderSocketEvent.AppMetadataChange,
|
||||||
({ metadata }: { metadata: any }) => {
|
({ metadata }: { metadata: any }) => {
|
||||||
|
console.log("hello?")
|
||||||
appStore.syncMetadata(metadata)
|
appStore.syncMetadata(metadata)
|
||||||
themeStore.syncMetadata(metadata)
|
themeStore.syncMetadata(metadata)
|
||||||
navigationStore.syncMetadata(metadata)
|
navigationStore.syncMetadata(metadata)
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {
|
||||||
GetSignedUploadUrlRequest,
|
GetSignedUploadUrlRequest,
|
||||||
GetSignedUploadUrlResponse,
|
GetSignedUploadUrlResponse,
|
||||||
ProcessAttachmentResponse,
|
ProcessAttachmentResponse,
|
||||||
|
PWAManifest,
|
||||||
ServeAppResponse,
|
ServeAppResponse,
|
||||||
ServeBuilderPreviewResponse,
|
ServeBuilderPreviewResponse,
|
||||||
ServeClientLibraryResponse,
|
ServeClientLibraryResponse,
|
||||||
|
@ -189,8 +190,9 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
|
||||||
const sideNav = appInfo.navigation.navigation === "Left"
|
const sideNav = appInfo.navigation.navigation === "Left"
|
||||||
const hideFooter =
|
const hideFooter =
|
||||||
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
|
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
|
||||||
const themeVariables = getThemeVariables(appInfo?.theme)
|
const themeVariables = getThemeVariables(appInfo?.theme || {})
|
||||||
|
const hasPWA = Object.keys(appInfo.pwa || {}).length > 0
|
||||||
|
const manifestUrl = hasPWA ? `/api/apps/${appId}/manifest.json` : ""
|
||||||
if (!env.isJest()) {
|
if (!env.isJest()) {
|
||||||
const plugins = await objectStore.enrichPluginURLs(appInfo.usedPlugins)
|
const plugins = await objectStore.enrichPluginURLs(appInfo.usedPlugins)
|
||||||
/*
|
/*
|
||||||
|
@ -223,8 +225,25 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
|
||||||
|
|
||||||
const { head, html, css } = AppComponent.render({ props })
|
const { head, html, css } = AppComponent.render({ props })
|
||||||
const appHbs = loadHandlebarsFile(appHbsPath)
|
const appHbs = loadHandlebarsFile(appHbsPath)
|
||||||
|
|
||||||
|
let extraHead = ""
|
||||||
|
if (hasPWA) {
|
||||||
|
extraHead = `<link rel="manifest" href="${manifestUrl}">`
|
||||||
|
|
||||||
|
extraHead += `<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="${
|
||||||
|
appInfo.pwa.short_name || appInfo.name
|
||||||
|
}">`
|
||||||
|
if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) {
|
||||||
|
let appleIconUrl = appInfo.pwa.icons[0].src
|
||||||
|
console.log(appleIconUrl)
|
||||||
|
extraHead += `<link rel="apple-touch-icon" sizes="180x180" href="${appleIconUrl}">`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = await processString(appHbs, {
|
ctx.body = await processString(appHbs, {
|
||||||
head,
|
head: `${head}${extraHead}`,
|
||||||
body: html,
|
body: html,
|
||||||
css: `:root{${themeVariables}} ${css.code}`,
|
css: `:root{${themeVariables}} ${css.code}`,
|
||||||
appId,
|
appId,
|
||||||
|
@ -365,3 +384,59 @@ export const getSignedUploadURL = async function (
|
||||||
|
|
||||||
ctx.body = { signedUrl, publicUrl }
|
ctx.body = { signedUrl, publicUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const serveManifest = async function (ctx: UserCtx<void, any>) {
|
||||||
|
const appId = context.getAppId()
|
||||||
|
if (!appId) {
|
||||||
|
ctx.status = 404
|
||||||
|
ctx.body = { message: "App not found" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = context.getAppDB({ skip_setup: true })
|
||||||
|
const appInfo = await db.get<App>(DocumentType.APP_METADATA)
|
||||||
|
|
||||||
|
if (!appInfo.pwa) {
|
||||||
|
ctx.status = 404
|
||||||
|
ctx.body = { message: "PWA not configured for this app" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: PWAManifest = {
|
||||||
|
name: appInfo.pwa.name || appInfo.name,
|
||||||
|
short_name: appInfo.pwa.short_name || appInfo.name,
|
||||||
|
description: appInfo.pwa.description || "",
|
||||||
|
start_url:
|
||||||
|
`/app${appInfo.url}#${appInfo.pwa.start_url}` || `/app/${appInfo.url}`,
|
||||||
|
display: appInfo.pwa.display || "standalone",
|
||||||
|
background_color: appInfo.pwa.background_color || "#FFFFFF",
|
||||||
|
theme_color: appInfo.pwa.theme_color || "#FFFFFF",
|
||||||
|
icons: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) {
|
||||||
|
manifest.icons = appInfo.pwa.icons.map(icon => {
|
||||||
|
let src = icon.src
|
||||||
|
|
||||||
|
if (src && src.startsWith("/") && !src.startsWith("//")) {
|
||||||
|
const origin = ctx.request.origin
|
||||||
|
src = `${origin}${src}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...icon,
|
||||||
|
src,
|
||||||
|
type: icon.type || "image/png",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set("Content-Type", "application/json")
|
||||||
|
ctx.body = manifest
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error serving manifest:", error)
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = { message: "Error generating manifest" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ router.param("file", async (file: any, ctx: any, next: any) => {
|
||||||
router
|
router
|
||||||
.get("/builder/:file*", controller.serveBuilder)
|
.get("/builder/:file*", controller.serveBuilder)
|
||||||
.get("/api/assets/client", controller.serveClientLibrary)
|
.get("/api/assets/client", controller.serveClientLibrary)
|
||||||
|
.get("/api/apps/:appId/manifest.json", controller.serveManifest)
|
||||||
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
|
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
|
||||||
.post("/api/beta/:feature", controller.toggleBetaUiFeature)
|
.post("/api/beta/:feature", controller.toggleBetaUiFeature)
|
||||||
.post(
|
.post(
|
||||||
|
|
|
@ -29,6 +29,7 @@ export interface App extends Document {
|
||||||
snippets?: Snippet[]
|
snippets?: Snippet[]
|
||||||
creationVersion?: string
|
creationVersion?: string
|
||||||
updatedBy?: string
|
updatedBy?: string
|
||||||
|
pwa?: PWAManifest
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppInstance {
|
export interface AppInstance {
|
||||||
|
@ -82,3 +83,15 @@ export interface AppFeatures {
|
||||||
export interface AutomationSettings {
|
export interface AutomationSettings {
|
||||||
chainAutomations?: boolean
|
chainAutomations?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PWAManifest {
|
||||||
|
name: string
|
||||||
|
short_name: string
|
||||||
|
description: string
|
||||||
|
icons: { src: string; sizes: string; type: string }[]
|
||||||
|
background_color: string
|
||||||
|
theme_color: string
|
||||||
|
display?: string
|
||||||
|
start_url?: string
|
||||||
|
scope?: string
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue