@silexlabs/silex
Version:
Free and easy website builder for everyone.
276 lines (261 loc) • 10.7 kB
text/typescript
/*
* Silex website builder, free/libre no-code tool for makers.
* Copyright (c) 2023 lexoyo and Silex Labs foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ConnectorId, WebsiteId, WebsiteData, ConnectorUser, ConnectorType, ApiError } from '../../types'
import { websiteLoad, websiteSave } from '../api'
import { cmdLogin, eventLoggedIn, eventLoggedOut, getCurrentUser, updateUser } from './LoginDialog'
import { addTempDataToAssetUrl, addTempDataToStyles, removeTempDataFromAssetUrl, removeTempDataFromStyles } from '../assetUrl'
import { PublicationStatus, PublishableEditor } from './PublicationManager'
import { ClientEvent } from '../events'
import { Page, PageProperties, ProjectData } from 'grapesjs'
import { html, render, TemplateResult } from 'lit-html'
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'
export const cmdPauseAutoSave = 'pause-auto-save'
const loader = document.querySelector('.silex-loader') as HTMLDivElement
function renderLoader(text: string, current: number, total: number) {
if (loader) {
render(getLoaderHtml(text, current, total), loader)
}
}
if (loader) {
loader.innerHTML = ''
renderLoader('Loading', 0, 0)
setTimeout(() => loader.classList.add('silex-loader--active'))
}
function getLoaderHtml(text: string, current: number, total: number): TemplateResult {
return html`
<div class="silex-loader__text">
<div>${ unsafeHTML(text) }</div>
<progress
class="silex-loader__progress"
max="${total}" value="${current}"></progress>
</div>
`
}
// Wait for the next frame to avoid blocking the main thread
function nextFrame(): Promise<void> {
const fn = window.requestIdleCallback ?? window.requestAnimationFrame
return new Promise(resolve => fn(() => resolve()))
}
// Mechanism to keep only the last save during publication
let lastPendingSaving: WebsiteData = null
// Mechanism to avoid concurrent saving
// Note: there is already such a mechanism in grapesjs but it fails when the save is delayed by the publication
let isSaving = false
export const storagePlugin = (editor: PublishableEditor) => {
// Add useful commands
editor.Commands.add(cmdPauseAutoSave, {
run: () => {
if(!editor.StorageManager.isAutosave()) console.warn('Autosave is not enabled')
editor.StorageManager.setAutosave(false)
},
stop: () => {
if(editor.StorageManager.isAutosave()) console.warn('Autosave is already enabled')
editor.editor.set('changesCount', 0)
editor.UndoManager.clear()
editor.StorageManager.setAutosave(true)
},
})
// Add the storage connector
editor.Storage.add('connector', {
async load(options: { id: WebsiteId, connectorId: ConnectorId, mode: '' | 'progressive' }): Promise<ProjectData> {
try {
renderLoader('Loading user data', 0, 3)
await nextFrame()
const user: ConnectorUser = await getCurrentUser(editor) ?? await updateUser(editor, ConnectorType.STORAGE, options.connectorId)
if (!user) throw new ApiError('Not logged in', 401)
renderLoader('Loading website', 1, 3)
await nextFrame()
const data = await websiteLoad({ websiteId: options.id, connectorId: user.storage.connectorId }) as WebsiteData
editor.runCommand(cmdPauseAutoSave)
if (data.assets) data.assets = addTempDataToAssetUrl(data.assets, options.id, user.storage.connectorId)
if (data.styles) data.styles = addTempDataToStyles(data.styles, options.id, user.storage.connectorId)
if (options.mode == 'progressive') {
if (!data.pages) {
// This happens when the website was just created
// Let grapesjs create the pages in the frontend
} else {
const { pages, pagesFolder, ...rest } = data
// Load any additional project data, e.g. symbols, but not the ones we progressive load
renderLoader('Loading styles, assets and symbols', 0, pages.length + 1)
await nextFrame()
// Add to the project, everything but pages
editor.loadProjectData(rest)
editor.getModel().set('pagesFolder', pagesFolder)
// Add the pages to the project
await progressiveLoadPages(editor, pages)
await nextFrame()
// Trigger symbol update to recount instances now that pages are loaded
editor.trigger('symbol')
await nextFrame()
// Select the first page
const firstPage = editor.Pages.getAll()[0]
if (firstPage) editor.Pages.select(firstPage)
}
}
// Always return the full data in the end
renderLoader('Starting', 1, 1)
await nextFrame()
editor.once('canvas:frame:load', () => {
setTimeout(() => { // This is needed in chrome, otherwise a save is triggered
editor.stopCommand(cmdPauseAutoSave)
}, 500)
})
return data
} catch (err) {
editor.UndoManager.clear()
if (err.httpStatusCode === 401) {
editor.once(eventLoggedIn, async () => {
// This should work but well it doesn't... window.location.reload()
// Here comes the workaround:
// eslint-disable-next-line no-self-assign
window.location.href = window.location.href
return null
// try {
// await this.load(options)
// } catch (err) {
// console.error('connectorPlugin load error', err)
// // Breaks grapesjs: throw err
// editor.trigger('storage:error:load', err)
// return null
// }
})
editor.Commands.run(cmdLogin)
}
console.error('connectorPlugin load error', err)
// Breaks grapesjs: throw err
editor.trigger('storage:error:load', err)
}
},
async store(data: WebsiteData, options: { id: WebsiteId, connectorId: ConnectorId }) {
// Be sure that it is immutable
const myData = { ...data }
return await this.addToQueue(myData, options)
},
async addToQueue(data: WebsiteData, options: { id: WebsiteId, connectorId: ConnectorId }) {
// Handle concurrent saving
if (lastPendingSaving && lastPendingSaving !== data) {
// Cancel previous saving
}
lastPendingSaving = data
// Go ahaed and save
return this.doStore(lastPendingSaving, options)
},
async doStore(data: WebsiteData, options: { id: WebsiteId, connectorId: ConnectorId }) {
if (editor.PublicationManager?.status === PublicationStatus.STATUS_PENDING) {
// Publication is pending, save is delayed
return new Promise((resolve) => {
editor.once(ClientEvent.PUBLISH_END, () => {
resolve(this.doStore(data, options))
})
})
}
try {
if (isSaving) {
// Concurrent saving, save is delayed
editor.once('storage:end:store', () => {
this.doStore(data, options)
})
} else {
if (lastPendingSaving === data) {
lastPendingSaving = null
isSaving = true
const user = await getCurrentUser(editor)
if (user) {
data.assets = removeTempDataFromAssetUrl(data.assets)
data.styles = removeTempDataFromStyles(data.styles)
data.pagesFolder = editor.getModel().get('pagesFolder')
await websiteSave({ websiteId: options.id, connectorId: user.storage.connectorId, data })
isSaving = false
} else {
// This should never happen,
// because the user canot save when they never logged in before
}
} else {
// Canceled saving
}
}
} catch (err) {
console.error('connectorPlugin store error', err)
isSaving = false
if (err.httpStatusCode === 401) {
editor.once(eventLoggedIn, () => {
isSaving = false
return editor.Storage.store(data, options)
})
}
// Breaks grapesjs: throw err
editor.trigger('storage:error:load', err)
}
}
})
// Handle errors
editor.on('storage:error:load', (err: Error) => {
handleError(err)
})
editor.on('storage:error:store', (err: Error) => {
handleError(err)
})
editor.on('asset:upload:end', (err: Error) => {
editor.store()
})
editor.on('asset:upload:error', (err: Error) => {
handleError(err)
})
function handleError(_err: Error | string) {
const err: Error = typeof _err === 'string' ? JSON.parse(_err) : _err
console.error('Error with loading or saving website or uploading asset', err, err instanceof ApiError)
if (err instanceof ApiError) {
console.error('ApiError', err.httpStatusCode, err.message)
switch (err.httpStatusCode) {
case 404:
return editor.Modal.open({
title: 'Website not found',
content: `This website could not be found.<br><hr>${err.message}`,
})
case 403:
return editor.Modal.open({
title: 'Access denied',
content: `You are not allowed to access this website.<br><hr>${err.message}`,
})
case 401:
editor.trigger(eventLoggedOut)
return
default:
return editor.Modal.open({
title: 'Error',
content: `An error occured.<br>${err.message}`,
})
}
} else {
return editor.Modal.open({
title: 'Error',
content: `An error occured.<br>${err.message}`,
})
}
}
}
async function progressiveLoadPages(editor: PublishableEditor, pages: Page[]) {
editor.Pages.getAll().forEach(page => editor.Pages.remove(page))
let i = 0
for (const page of pages) {
renderLoader(`Loading page <strong>${++i}</strong> / ${pages.length + 1}`, i, pages.length + 1)
await nextFrame()
const newPage = editor.Pages.add({
...page,
} as PageProperties)
}
}