Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,34 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

#jsloader-section label,
#jsloader-snippet,
#jsloader-url {
#jsloader-form{
display: block;
max-width: 550px;
max-width: 650px;
padding-top: var(--app-navigation-padding);
width: 100%;

h2 {
display: block;
line-height: var(--default-clickable-area);
padding-inline-start: calc(2* var(--app-navigation-padding) + var(--default-clickable-area) - 30px);
}

.hint {
color: var(--color-text-maxcontrast);
margin-bottom: calc(2 * var(--default-grid-baseline));
}

input:invalid {
border-color: var(--color-element-error);
}

> * {
width: 100%;
}
}

#jsloader-snippet {
height: 200px;
min-width: 100%;
min-height: 200px;
margin-bottom: calc(2 * var(--default-grid-baseline));
}
67 changes: 0 additions & 67 deletions js/admin.js

This file was deleted.

13 changes: 13 additions & 0 deletions js/admin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { formElement, textareaElement, urlInputElement } from './elements.mjs'
import { onSave, onSnippetChange, onUrlChange } from './handlers.mjs'

// As this is a module script the content is executed after the DOM is ready
// setup event handlers on the input elements
formElement.addEventListener('submit', onSave)
textareaElement.addEventListener('input', onSnippetChange)
urlInputElement.addEventListener('input', onUrlChange)
43 changes: 43 additions & 0 deletions js/elements.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*!
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
*/

/**
* @type {HTMLFormElement}
*/
export const formElement = getElementById('jsloader-form')

/**
* @type {HTMLInputElement}
*/
export const urlInputElement = getElementById('jsloader-url')

/**
* @type {HTMLTextAreaElement}
*/
export const textareaElement = getElementById('jsloader-snippet')

/**
* @type {HTMLButtonElement}
*/
export const saveButtonElement = getElementById('jsloader-save')

/**
* @type {HTMLElement}
*/
export const iconElement = getElementById('jsloader-message')

/**
* Get an element by its id and throw an error if it is not found
*
* @param {string} id - The id of the element to find
* @return {HTMLElement} The found element
*/
function getElementById(id) {
const el = document.getElementById(id)
if (el === null) {
throw new Error(`Element with id ${id} not found`)
}
return el
}
113 changes: 113 additions & 0 deletions js/handlers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { formElement, iconElement, saveButtonElement, textareaElement, urlInputElement } from "./elements.mjs"

const URL_REGEX = /https?:\/\/([^\/]+)/

/**
* Handle the change event on the snippet textarea.
* If the URL input is empty, try to detect an URL from the snippet and fill it in.
* Otherwise just enable the save button.
*
* @return {void}
*/
export function onSnippetChange() {
saveButtonElement.disabled = false

if (urlInputElement.value === '' || !urlInputElement.validity.valid) {
const match = URL_REGEX.exec(textareaElement.value)
if (match !== null) {
urlInputElement.value = match[1]
}
}
}

/**
* Handle the change event on the URL input.
*
* If the URL is valid, enable the save button.
* Otherwise show an error message and keep the save button disabled.
*/
export function onUrlChange() {
urlInputElement.setCustomValidity('')

if (urlInputElement.value !== '' && urlInputElement.checkValidity?.() !== false) {
try {
// some browsers do not properly validate the URL, so re-do it manually
new URL(urlInputElement.value)
} catch {
urlInputElement.setCustomValidity('Invalid URL')
}
}
saveButtonElement.disabled = false
urlInputElement.reportValidity()
}

/**
* Handle the click event on the save button
*
* @param {SubmitEvent} event - the submit event
* @return {Promise<void>}
*/
export async function onSave(event) {
event.preventDefault()
const urlValue = urlInputElement.value
const snippetValue = textareaElement.value

if (urlValue !== '' && !urlInputElement.checkValidity()) {
urlInputElement.reportValidity()
return
}

try {
textareaElement.disabled = true
urlInputElement.disabled = true

await new Promise((success, error) => OCP.AppConfig.setValue('jsloader', 'snippet', snippetValue, { success, error }))
textareaElement.disabled = false

await new Promise((success, error) => OCP.AppConfig.setValue('jsloader', 'url', urlValue, { success, error }))
urlInputElement.disabled = false
showSuccess()

const cacheBuster = String(Number.parseInt(formElement.dataset.cachebuster) + 1)
await new Promise((success, error) => OCP.AppConfig.setValue('jsloader', 'cachebuster', cacheBuster, { success, error }))
formElement.dataset.cachebuster = cacheBuster
} catch (error) {
console.error('[jsloader] Failed to save the configuration', error)
showError()
} finally {
textareaElement.disabled = false
urlInputElement.disabled = false
}
}

/**
* Shows a loading icon in the message element
*/
function showLoading() {
iconElement.classList.remove('icon-success')
iconElement.classList.remove('icon-error')
iconElement.classList.add('icon-loading')
}

/**
* Shows a success icon in the message element
*/
function showSuccess() {
iconElement.classList.remove('icon-loading')
iconElement.classList.remove('icon-error')
iconElement.classList.add('icon-success')
}

/**
* Shows an error icon in the message element
*/
function showError() {
iconElement.classList.remove('icon-loading')
iconElement.classList.remove('icon-success')
iconElement.classList.add('icon-error')
}
17 changes: 10 additions & 7 deletions templates/settings-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
Util::addStyle(Application::APP_ID, 'admin');
?>

<div id="jsloader-section" class="section" data-cachebuster="<?php print_unescaped($_['cachebuster']); ?>">
<form id="jsloader-form" class="section" data-cachebuster="<?php print_unescaped($_['cachebuster']); ?>">
<h2 class="inlineblock"><?php p($l->t('JavaScript loader')); ?></h2>
<p>
<label for="jsloader-snippet">
<?php p($l->t('Paste the JS code snippet here. It will be loaded on every page.')); ?>
</p>
</label>
<textarea id="jsloader-snippet"><?php print_unescaped($_['snippet']); ?></textarea>
<label for="jsloader-url"><?php p($l->t('Domain where external JavaScript is loaded from. This is needed to work with the CSP policy that is in place. It is tried to automatically detect this based on the snippet above if empty.')); ?></label>
<input id="jsloader-url" name="jsloader-url" type="text" value="<?php print_unescaped($_['url']); ?>">
<button id="jsloader-save" class="btn btn-primary" disabled="disabled"><?php p($l->t('Save')); ?></button>
<label for="jsloader-url"><?php p($l->t('Domain where external JavaScript is loaded from.')); ?></label>
<input id="jsloader-url" aria-describedby="jsloader-url-hint" name="jsloader-url" type="url" value="<?php print_unescaped($_['url']); ?>">
<div id="jsloader-url-hint" class="hint">
<?php p($l->t('This is needed to work with the CSP policy that is in place. It is tried to automatically detect this based on the snippet above if empty.')); ?>
</div>
<button id="jsloader-save" class="btn btn-primary" disabled="disabled" type="submit"><?php p($l->t('Save')); ?></button>
<span id="jsloader-message" class="msg"></span>
</div>
</form>
Loading