Admin Panel/Settings/Layout, for PWA: Custom head meta, link, icons, assetlinks.json, site.webmanifest.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2026-02-15 21:49:55 +02:00
parent dace6b78c0
commit b5a13f0206
16 changed files with 1016 additions and 3 deletions

View file

@ -15,6 +15,7 @@ import '/client/components/settings/migrationProgress';
// Import cron settings
import '/client/components/settings/cronSettings';
// Custom head tags
// Mirror Meteor login token into a cookie for server-side file route auth
// This enables cookie-based auth for /cdn/storage/* without leaking ROOT_URL

View file

@ -230,3 +230,22 @@ li.has-error .form-group .wekan-form-control {
border-color: #a94442;
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
}
/* Constrain textarea elements with balanced left/right spacing */
.setting-content .content-body .main-body textarea.wekan-form-control {
width: calc(100% - 20px);
margin: 0 10px;
max-width: calc(100vw - 320px);
box-sizing: border-box;
}
/* For nested custom head/manifest/assetlinks sections */
.setting-content .content-body .main-body .custom-head-settings textarea.wekan-form-control,
.setting-content .content-body .main-body .custom-manifest-settings textarea.wekan-form-control,
.setting-content .content-body .main-body .custom-assetlinks-settings textarea.wekan-form-control {
width: calc(100% - 20px);
margin-left: 0;
margin-right: 10px;
max-width: calc(100vw - 240px);
box-sizing: border-box;
}

View file

@ -460,6 +460,41 @@ template(name='layoutSettings')
textarea#support-page-text.wekan-form-control= currentSetting.supportPageText
li
button.js-support-save.primary {{_ 'save'}}
li
a.flex.js-toggle-custom-head
.materialCheckBox(class="{{#if currentSetting.customHeadEnabled}}is-checked{{/if}}")
span {{_ 'custom-head-tags-enabled'}}
li
.custom-head-settings(class="{{#if currentSetting.customHeadEnabled}}{{else}}hide{{/if}}")
ul
li
.title {{_ 'custom-head-meta-tags'}}
textarea#custom-head-meta.wekan-form-control= customHeadMetaTagsValue
li
.title {{_ 'custom-head-link-tags'}}
textarea#custom-head-links.wekan-form-control= customHeadLinkTagsValue
li
a.flex.js-toggle-custom-manifest
.materialCheckBox(class="{{#if currentSetting.customManifestEnabled}}is-checked{{/if}}")
span {{_ 'custom-manifest-enabled'}}
li
.custom-manifest-settings(class="{{#if currentSetting.customManifestEnabled}}{{else}}hide{{/if}}")
.title {{_ 'custom-head-manifest-content'}}
textarea#custom-manifest-content.wekan-form-control= customManifestContentValue
li
button.js-custom-head-save.primary {{_ 'save'}}
li
a.flex.js-toggle-custom-assetlinks
.materialCheckBox(class="{{#if currentSetting.customAssetLinksEnabled}}is-checked{{/if}}")
span {{_ 'custom-assetlinks-enabled'}}
li
.custom-assetlinks-settings(class="{{#if currentSetting.customAssetLinksEnabled}}{{else}}hide{{/if}}")
ul
li
.title {{_ 'custom-assetlinks-content'}}
textarea#custom-assetlinks-content.wekan-form-control= customAssetLinksContentValue
li
button.js-custom-assetlinks-save.primary {{_ 'save'}}
li.layout-form
.title {{_ 'oidc-button-text'}}
.form-group

View file

@ -759,6 +759,182 @@ BlazeComponent.extendComponent({
this.setLoading(false);
},
toggleCustomHead() {
this.setLoading(true);
const customHeadEnabled = !$('.js-toggle-custom-head .materialCheckBox').hasClass('is-checked');
$('.js-toggle-custom-head .materialCheckBox').toggleClass('is-checked');
$('.custom-head-settings').toggleClass('hide');
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: { customHeadEnabled },
});
this.setLoading(false);
},
toggleCustomManifest() {
this.setLoading(true);
const customManifestEnabled = !$('.js-toggle-custom-manifest .materialCheckBox').hasClass('is-checked');
$('.js-toggle-custom-manifest .materialCheckBox').toggleClass('is-checked');
$('.custom-manifest-settings').toggleClass('hide');
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: { customManifestEnabled },
});
this.setLoading(false);
},
saveCustomHeadSettings() {
this.setLoading(true);
const customHeadMetaTags = $('#custom-head-meta').val() || '';
let customManifestContent = $('#custom-manifest-content').val() || '';
// Validate and clean JSON if present
if (customManifestContent.trim()) {
const cleanResult = this.cleanAndValidateJSON(customManifestContent);
if (cleanResult.error) {
this.setLoading(false);
alert(`Invalid manifest JSON: ${cleanResult.error}`);
return;
}
customManifestContent = cleanResult.json;
// Update the textarea with cleaned version
$('#custom-manifest-content').val(customManifestContent);
}
const customHeadLinkTags = $('#custom-head-links').val() || '';
try {
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: {
customHeadMetaTags,
customHeadLinkTags,
customManifestContent,
},
});
} catch (e) {
return;
} finally {
this.setLoading(false);
}
},
cleanAndValidateJSON(content) {
if (!content || !content.trim()) {
return { json: content };
}
try {
// Try to parse as-is
const parsed = JSON.parse(content);
return { json: JSON.stringify(parsed, null, 2) };
} catch (e) {
const errorMsg = e.message;
// If error is "unexpected non-whitespace character after JSON data"
if (errorMsg.includes('unexpected non-whitespace character after JSON data')) {
try {
// Try to find and extract valid JSON by finding matching braces/brackets
const trimmed = content.trim();
let depth = 0;
let endPos = -1;
let inString = false;
let escapeNext = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"' && !escapeNext) {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{' || char === '[') {
depth++;
} else if (char === '}' || char === ']') {
depth--;
if (depth === 0) {
endPos = i + 1;
break;
}
}
}
if (endPos > 0) {
const cleanedContent = trimmed.substring(0, endPos);
const parsed = JSON.parse(cleanedContent);
return { json: JSON.stringify(parsed, null, 2) };
}
} catch (fixError) {
// If fix attempt fails, return original error
}
}
// Remove trailing commas (common error)
if (errorMsg.includes('Unexpected token')) {
try {
const fixed = content.replace(/,(\s*[}\]])/g, '$1');
const parsed = JSON.parse(fixed);
return { json: JSON.stringify(parsed, null, 2) };
} catch (fixError) {
// Continue to error return
}
}
return { error: errorMsg };
}
},
toggleCustomAssetLinks() {
this.setLoading(true);
const customAssetLinksEnabled = !$('.js-toggle-custom-assetlinks .materialCheckBox').hasClass('is-checked');
$('.js-toggle-custom-assetlinks .materialCheckBox').toggleClass('is-checked');
$('.custom-assetlinks-settings').toggleClass('hide');
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: { customAssetLinksEnabled },
});
this.setLoading(false);
},
saveCustomAssetLinksSettings() {
this.setLoading(true);
let customAssetLinksContent = $('#custom-assetlinks-content').val() || '';
// Validate and clean JSON if present
if (customAssetLinksContent.trim()) {
const cleanResult = this.cleanAndValidateJSON(customAssetLinksContent);
if (cleanResult.error) {
this.setLoading(false);
alert(`Invalid assetlinks JSON: ${cleanResult.error}`);
return;
}
customAssetLinksContent = cleanResult.json;
// Update the textarea with cleaned version
$('#custom-assetlinks-content').val(customAssetLinksContent);
}
try {
Settings.update(ReactiveCache.getCurrentSetting()._id, {
$set: {
customAssetLinksContent,
},
});
} catch (e) {
return;
} finally {
this.setLoading(false);
}
},
saveSupportSettings() {
this.setLoading(true);
const supportTitle = ($('#support-title').val() || '').trim();
@ -808,6 +984,11 @@ BlazeComponent.extendComponent({
'click a.js-toggle-support': this.toggleSupportPage,
'click a.js-toggle-support-public': this.toggleSupportPublic,
'click button.js-support-save': this.saveSupportSettings,
'click a.js-toggle-custom-head': this.toggleCustomHead,
'click a.js-toggle-custom-manifest': this.toggleCustomManifest,
'click button.js-custom-head-save': this.saveCustomHeadSettings,
'click a.js-toggle-custom-assetlinks': this.toggleCustomAssetLinks,
'click button.js-custom-assetlinks-save': this.saveCustomAssetLinksSettings,
'click a.js-toggle-display-authentication-method': this
.toggleDisplayAuthenticationMethod,
},

View file

@ -1,4 +1,10 @@
import { ReactiveCache } from '/imports/reactiveCache';
import {
DEFAULT_ASSETLINKS,
DEFAULT_HEAD_LINKS,
DEFAULT_HEAD_META,
DEFAULT_SITE_MANIFEST,
} from '/imports/lib/customHeadDefaults';
import { Blaze } from 'meteor/blaze';
import { Session } from 'meteor/session';
import {
@ -42,6 +48,38 @@ Blaze.registerHelper('currentSetting', () => {
return ret;
});
Blaze.registerHelper('customHeadMetaTagsValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customHeadMetaTags === 'string') {
return setting.customHeadMetaTags;
}
return DEFAULT_HEAD_META;
});
Blaze.registerHelper('customHeadLinkTagsValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customHeadLinkTags === 'string') {
return setting.customHeadLinkTags;
}
return DEFAULT_HEAD_LINKS;
});
Blaze.registerHelper('customManifestContentValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customManifestContent === 'string') {
return setting.customManifestContent;
}
return DEFAULT_SITE_MANIFEST;
});
Blaze.registerHelper('customAssetLinksContentValue', () => {
const setting = ReactiveCache.getCurrentSetting();
if (setting && typeof setting.customAssetLinksContent === 'string') {
return setting.customAssetLinksContent;
}
return DEFAULT_ASSETLINKS;
});
Blaze.registerHelper('currentUser', () => {
const ret = ReactiveCache.getCurrentUser();
return ret;

View file

@ -758,11 +758,12 @@ Utils = {
},
setCustomUI(data) {
const productName = (data && data.productName) ? data.productName : 'Wekan';
const currentBoard = Utils.getCurrentBoard();
if (currentBoard) {
document.title = `${currentBoard.title} - ${data.productName}`;
document.title = `${currentBoard.title} - ${productName}`;
} else {
document.title = `${data.productName}`;
document.title = productName;
}
},