diff --git a/public/js/components/Cms/CmsAdmin.js b/public/js/components/Cms/CmsAdmin.js
new file mode 100644
index 000000000..cb374dba5
--- /dev/null
+++ b/public/js/components/Cms/CmsAdmin.js
@@ -0,0 +1,303 @@
+import ApiCmsAdmin from '../../api/factory/cmsadmin.js';
+import ContentTree from './Tree/ContentTree.js';
+import ContentHeader from './Header/ContentHeader.js';
+import Properties from './Tabs/Properties.js';
+import ContentTab from './Tabs/Content.js';
+import Permissions from './Tabs/Permissions.js';
+import Hierarchy from './Tabs/Hierarchy.js';
+import History from './Tabs/History.js';
+import Delete from './Tabs/Delete.js';
+import Clickstats from './Tabs/Clickstats.js';
+import HorizontalSplit from '../horizontalsplit/horizontalsplit.js';
+
+const TAB_COMPONENTS = {
+ properties: Properties,
+ content: ContentTab,
+ permissions: Permissions,
+ hierarchy: Hierarchy,
+ history: History,
+ delete: Delete
+};
+
+const TABS = [
+ { key: 'properties', phrase: 'cms/tabEigenschaften' },
+ { key: 'content', phrase: 'cms/tabInhalt' },
+ { key: 'permissions', phrase: 'cms/tabRechte' },
+ { key: 'hierarchy', phrase: 'cms/tabHierarchie' },
+ { key: 'history', phrase: 'cms/tabHistory' },
+ { key: 'delete', phrase: 'cms/tabLoeschen' }
+];
+
+// The view renders the clickstats attribute only for an admin, and only with the config
+// flag set.
+const CLICKSTATS_TAB = { key: 'clickstats', phrase: 'cms/tabKlickstatistik' };
+
+export default {
+ name: 'CmsAdmin',
+ components: {
+ 'cms-tree': ContentTree,
+ 'cms-content-header': ContentHeader,
+ 'horizontal-split': HorizontalSplit,
+ ...TAB_COMPONENTS
+ },
+ props: {
+ cmsRoot: String,
+ authUid: String,
+ defaultLanguage: { type: String, default: 'German' },
+ clickstats: { type: Boolean, default: false }
+ },
+ data() {
+ return {
+ contentId: null,
+ sprache: null,
+ version: null,
+ tab: 'properties',
+ contentInfo: null,
+ pending: 0
+ };
+ },
+ computed: {
+ tabs() {
+ return this.clickstats ? TABS.concat([CLICKSTATS_TAB]) : TABS;
+ },
+ tabComponents() {
+ return this.clickstats
+ ? { ...TAB_COMPONENTS, clickstats: Clickstats }
+ : TAB_COMPONENTS;
+ },
+ activeTabComponent() {
+ return this.tabComponents[this.tab] || null;
+ },
+ busy() {
+ return this.pending > 0;
+ }
+ },
+ watch: {
+ '$route.params': {
+ handler(params) {
+ const id = params.content_id ? Number(params.content_id) : null;
+ const sprache = params.sprache || null;
+ const version = params.version ? Number(params.version) : null;
+ // unknown tab fallback
+ const tab = this.tabComponents[params.tab] ? params.tab : 'properties';
+
+ if (id !== this.contentId || sprache !== this.sprache
+ || version !== this.version || tab !== this.tab) {
+ this.contentId = id;
+ this.sprache = sprache;
+ this.version = version;
+ this.tab = tab;
+
+ if (id === null) return;
+
+ if (this.contentInfo?.content_id !== id) {
+ this.loadContentInfo().then(() => { this.applyRouteDefaults(); });
+ } else {
+ this.applyRouteDefaults();
+ }
+ }
+ },
+ immediate: true
+ }
+ },
+ methods: {
+ loadContentInfo() {
+ if (this.contentId === null) {
+ this.contentInfo = null;
+ return Promise.resolve();
+ }
+ this.pending++;
+ return this.$api
+ .call(ApiCmsAdmin.getContent(this.contentId))
+ .then(result => {
+ this.contentInfo = result.data;
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.pending--; });
+ },
+
+ // Language, version and tab are optional in the address. Fill in what the address
+ // leaves out, and replace a value that the content does not have. Then write the
+ // complete address back, so a reload and a copied link both work.
+ applyRouteDefaults() {
+ if (this.contentId === null || !this.contentInfo) return;
+
+ const languages = this.contentInfo.languages || [];
+ const versions = this.contentInfo.versions || {};
+
+ let lang = this.sprache;
+ if (lang === null || !languages.includes(lang)) {
+ lang = languages.includes(this.defaultLanguage)
+ ? this.defaultLanguage
+ : languages[0] || null;
+ }
+
+ const langVersions = (lang && versions[lang]) ? versions[lang] : [];
+ let ver = this.version;
+ if (ver === null || !langVersions.includes(ver)) {
+ ver = langVersions.length ? Math.max(...langVersions) : null;
+ }
+
+ if (lang === this.sprache && ver === this.version) return;
+
+ this.sprache = lang;
+ this.version = ver;
+ // Replace, or the short address stays in the history and the back button
+ // walks through both forms of the same page.
+ this.pushRoute(true);
+ },
+
+ selectContent(content_id) {
+ if (content_id === null) {
+ this.contentId = null;
+ this.sprache = null;
+ this.version = null;
+ this.tab = 'properties';
+ this.contentInfo = null;
+ this.$router.push({ name: 'index' });
+ return;
+ }
+
+ // Set id, language and version in one flush, or the tabs query the new content
+ // with the version of the old one.
+ this.pending++;
+ this.$api
+ .call(ApiCmsAdmin.getContent(content_id))
+ .then(result => {
+ this.contentId = content_id;
+ this.contentInfo = result.data;
+ this.preselectLanguageVersion();
+ this.pushRoute();
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.pending--; });
+ },
+
+ selectTab(tab) {
+ this.tab = tab;
+ this.pushRoute();
+ },
+
+ selectLanguageVersion(sprache, version) {
+ this.sprache = sprache;
+ this.version = version;
+ this.pushRoute();
+ },
+
+ // Three grades of tree update. Pick the cheapest one that stays correct.
+ // A full reload drops the expanded branches and refetches the filter meta,
+ // so keep it for changes that move nodes between branches.
+ reloadTree() {
+ if (this.$refs.tree) this.$refs.tree.reload();
+ },
+
+ refreshTreeNode(contentId) {
+ if (this.$refs.tree) this.$refs.tree.refreshNode(contentId);
+ },
+
+ patchTreeNode(payload) {
+ if (this.$refs.tree) this.$refs.tree.patchNode(payload.content_id, payload.patch);
+ },
+
+ preselectLanguageVersion() {
+ if (!this.contentInfo) return;
+
+ const languages = this.contentInfo.languages || [];
+ const versions = this.contentInfo.versions || {};
+
+ let lang = languages.includes(this.defaultLanguage)
+ ? this.defaultLanguage
+ : languages[0] || null;
+
+ let ver = null;
+ if (lang && versions[lang] && versions[lang].length) {
+ ver = Math.max(...versions[lang]);
+ }
+
+ this.sprache = lang;
+ this.version = ver;
+ },
+
+ pushRoute(replace = false) {
+ if (this.contentId === null) {
+ this.$router.push({ name: 'index' });
+ return;
+ }
+
+ // The optional parts are positional. Stop at the first missing one, or the
+ // address carries an empty segment.
+ const params = { content_id: this.contentId };
+ if (this.sprache !== null) {
+ params.sprache = this.sprache;
+ if (this.version !== null) {
+ params.version = this.version;
+ params.tab = this.tab;
+ }
+ }
+
+ const target = { name: 'content', params };
+ if (replace) {
+ this.$router.replace(target);
+ } else {
+ this.$router.push(target);
+ }
+ }
+ },
+ template: `
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/keinEintragGewaehlt') }}
+
+
+
+
+
+
+ {{ $p.t('ui/loading') }}
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Form/ContentcomponentDialog.js b/public/js/components/Cms/Form/ContentcomponentDialog.js
new file mode 100644
index 000000000..ff5fe7ded
--- /dev/null
+++ b/public/js/components/Cms/Form/ContentcomponentDialog.js
@@ -0,0 +1,381 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+import {
+ catalog,
+ getDescriptor,
+ CONTENTCOMPONENT_CLASS,
+ CONTENTCOMPONENT_ATTR
+} from '../../Contentcomponents/catalog.js';
+
+/**
+ * The insert and edit dialogs for a contentcomponent marker.
+ *
+ * Lives apart from FieldWysiwyg so that file keeps to the editor itself. Everything that
+ * knows about the catalog, the marker markup and the dialogs is here.
+ */
+
+// Helper fields a picker adds to a dialog. They must not collide with a prop name, and
+// markerHtml ignores them because it walks the catalog, not the dialog data.
+const FELD_KATEGORIE = '__kategorie';
+const FELD_DOKUMENT = '__dokument';
+
+// Filled in createContentcomponentDialog. The factory is no Vue component, so the
+// phrases plugin is handed in instead of read from an instance.
+let t = key => key;
+
+// A function, not a constant: the text is translated when the dialog is built.
+const leereAuswahl = () => ({ value: '', text: t('ccAuswaehlen') });
+
+// A prop source fills a dialog field with a list from the server. The key is the
+// "source" value in the catalog. A prop without a known source gets a plain text field.
+const SOURCES = {
+ oe: {
+ request: () => ApiCmsAdmin.getOrganisationseinheiten(),
+ item: oe => ({
+ value: oe.oe_kurzbz,
+ text: (oe.bezeichnung || oe.oe_kurzbz)
+ + (oe.aktiv ? '' : ' (' + t('inaktiv') + ')')
+ })
+ },
+ dmskategorie: {
+ request: () => ApiCmsAdmin.getDmsKategorien(),
+ item: kat => ({
+ value: kat.kategorie_kurzbz,
+ text: (kat.bezeichnung || kat.kategorie_kurzbz) + ' (' + kat.anzahl + ')'
+ })
+ },
+ // Surname first
+ person: {
+ request: () => ApiCmsAdmin.getMitarbeiter(),
+ item: person => ({
+ value: person.uid,
+ text: person.nachname + ' ' + person.vorname + ' (' + person.uid + ')'
+ })
+ }
+};
+
+function escapeAttr(value)
+{
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+// Builds the marker. It stays empty: CIS4 renders the component into it, and every other
+// renderer shows nothing. NOTE: if a marker disappears on save, check the TinyMCE
+// serializer first. noneditable keeps the node, but an empty block is the exposed part.
+function markerHtml(name, descriptor, data)
+{
+ let attrs = '';
+
+ for (const propName of Object.keys(descriptor.props || {}))
+ {
+ const value = data[propName];
+ // false is an unchecked box. The absent attribute already means false.
+ if (value === '' || value === false || value === undefined || value === null)
+ continue;
+ attrs += ' data-' + propName + '="' + escapeAttr(value) + '"';
+ }
+
+ return '
';
+}
+
+// Reads an existing marker back into dialog values.
+function readMarker(node)
+{
+ const name = node.getAttribute(CONTENTCOMPONENT_ATTR);
+ const descriptor = getDescriptor(name);
+
+ if (!descriptor)
+ return null;
+
+ const values = {};
+ for (const propName of Object.keys(descriptor.props || {}))
+ values[propName] = node.getAttribute('data-' + propName) || '';
+
+ return { name: name, values: values };
+}
+
+/**
+ * @param {object} options { api: the $api plugin, onChanged: called after a write }
+ */
+export function createContentcomponentDialog(options)
+{
+ const api = options.api;
+ const onChanged = options.onChanged || function () {};
+
+ t = (key, args) => options.p.t('cms/' + key, args);
+
+ const sourceItems = {};
+ const sourceRequests = {};
+
+ // These lists are large and rarely needed. Load each one once, on demand.
+ function loadSource(key)
+ {
+ if (sourceRequests[key])
+ return sourceRequests[key];
+
+ const source = SOURCES[key];
+
+ sourceRequests[key] = api
+ .call(source.request())
+ .then(res => { sourceItems[key] = (res.data || []).map(source.item); })
+ .catch(() => { sourceItems[key] = []; });
+
+ return sourceRequests[key];
+ }
+
+ // Step one: pick the component. A second dialog then asks for its properties.
+ // Two dialogs avoid the redial logic a single changing dialog would need.
+ function openPicker(editor)
+ {
+ if (!catalog.length)
+ return;
+
+ editor.windowManager.open({
+ title: t('ccDialogTitel'),
+ initialData: { component: catalog[0].name },
+ body: {
+ type: 'panel',
+ items: [{
+ type: 'selectbox',
+ name: 'component',
+ label: t('ccContentcomponent'),
+ items: catalog.map(entry => ({
+ value: entry.name,
+ text: t(entry.labelPhrase)
+ }))
+ }]
+ },
+ buttons: [
+ { type: 'cancel', text: t('abbrechen') },
+ { type: 'submit', text: t('weiter'), primary: true }
+ ],
+ onSubmit(dialog) {
+ const name = dialog.getData().component;
+ dialog.close();
+ openProperties(editor, name, null, null);
+ }
+ });
+ }
+
+ // Step two. node is null for an insert and the marker element for an edit.
+ function openProperties(editor, name, values, node)
+ {
+ const descriptor = getDescriptor(name);
+
+ if (!descriptor)
+ return;
+
+ const specs = Object.entries(descriptor.props || {});
+
+ const sources = specs
+ .map(([, spec]) => spec.source)
+ .filter((key, i, all) => SOURCES[key] && all.indexOf(key) === i);
+
+ // A document picker needs the category list even though no prop declares it.
+ if (specs.some(([, spec]) => spec.picker === 'dms') && sources.indexOf('dmskategorie') === -1)
+ sources.push('dmskategorie');
+
+ Promise.all(sources.map(loadSource)).then(() => {
+ // Documents of the category currently chosen in the dialog. A category change
+ // refills this and redials, because the items of a selectbox are fixed once
+ // the dialog is built.
+ let dokumente = [];
+
+ // A stored value that is no longer in the list, for example a person who left.
+ // Without this extra option the select would fall back to its first entry and
+ // silently replace the value on save. slice() keeps the cache clean.
+ function listeFuer(propName, spec)
+ {
+ const liste = (sourceItems[spec.source] || []).slice();
+ const wert = values && values[propName];
+
+ if (wert && !liste.some(eintrag => eintrag.value === wert))
+ liste.unshift({ value: wert, text: t('ccNichtInDerAuswahl', [wert]) });
+
+ return liste;
+ }
+
+ function itemsBauen()
+ {
+ return specs.reduce((alle, [propName, spec]) => {
+ if (SOURCES[spec.source])
+ {
+ alle.push({
+ type: 'selectbox',
+ name: propName,
+ label: spec.labelPhrase ? t(spec.labelPhrase) : propName,
+ items: listeFuer(propName, spec)
+ });
+ return alle;
+ }
+
+ if (spec.type === 'boolean')
+ {
+ alle.push({
+ type: 'checkbox',
+ name: propName,
+ label: spec.labelPhrase ? t(spec.labelPhrase) : propName
+ });
+ return alle;
+ }
+
+ alle.push({
+ type: 'input',
+ name: propName,
+ label: spec.labelPhrase ? t(spec.labelPhrase) : propName
+ });
+
+ if (spec.picker === 'dms')
+ {
+ alle.push({
+ type: 'selectbox',
+ name: propName + FELD_KATEGORIE,
+ label: t('ccKategorie'),
+ items: [leereAuswahl()].concat(sourceItems.dmskategorie || [])
+ });
+ alle.push({
+ type: 'selectbox',
+ name: propName + FELD_DOKUMENT,
+ label: t('ccDokumentHinzufuegen'),
+ items: [leereAuswahl()].concat(dokumente)
+ });
+ }
+
+ return alle;
+ }, []);
+ }
+
+ function konfigBauen(daten)
+ {
+ return {
+ title: t(descriptor.labelPhrase),
+ initialData: daten,
+ body: { type: 'panel', items: itemsBauen() },
+ onChange(dialog, details) {
+ aufAenderung(dialog, details.name);
+ },
+ buttons: [
+ { type: 'cancel', text: t('abbrechen') },
+ { type: 'submit', text: t('uebernehmen'), primary: true }
+ ],
+ onSubmit(dialog) {
+ const html = markerHtml(name, descriptor, dialog.getData());
+ dialog.close();
+
+ if (node)
+ editor.dom.setOuterHTML(node, html);
+ else
+ editor.insertContent(html);
+
+ onChanged();
+ }
+ };
+ }
+
+ function aufAenderung(dialog, feld)
+ {
+ // A new category means a new document list, and only redial can replace
+ // the options of a selectbox.
+ if (feld.endsWith(FELD_KATEGORIE))
+ {
+ const ziel = feld.slice(0, -FELD_KATEGORIE.length);
+ const daten = dialog.getData();
+ const kategorie = daten[feld];
+
+ daten[ziel + FELD_DOKUMENT] = '';
+
+ if (!kategorie)
+ {
+ dokumente = [];
+ dialog.redial(konfigBauen(daten));
+ return;
+ }
+
+ api
+ .call(ApiCmsAdmin.getDmsKategorieDokumente(kategorie))
+ .then(res => {
+ dokumente = (res.data || []).map(dokument => ({
+ value: String(dokument.dms_id),
+ // A document outside the CIS search still renders here, so
+ // the editor has to see which one that is.
+ text: dokument.cis_suche
+ ? dokument.name
+ : t('ccNichtInCisSuche', [dokument.name])
+ }));
+ })
+ .catch(() => { dokumente = []; })
+ .then(() => { dialog.redial(konfigBauen(daten)); });
+
+ return;
+ }
+
+ // Choosing a document appends it and clears the select, so the next one can
+ // be picked straight away. The reset fires this handler again with an empty
+ // value, which the guard below drops.
+ if (feld.endsWith(FELD_DOKUMENT))
+ {
+ const ziel = feld.slice(0, -FELD_DOKUMENT.length);
+ const daten = dialog.getData();
+ const wert = daten[feld];
+
+ if (!wert)
+ return;
+
+ const ids = String(daten[ziel] || '')
+ .split(',')
+ .map(teil => teil.trim())
+ .filter(teil => teil);
+
+ if (ids.indexOf(wert) === -1)
+ ids.push(wert);
+
+ const patch = {};
+ patch[ziel] = ids.join(', ');
+ patch[feld] = '';
+ dialog.setData(patch);
+ }
+ }
+
+ // Every field needs a start value, otherwise the dialog reports it as unknown.
+ const startdaten = {};
+ for (const [propName, spec] of specs)
+ {
+ if (spec.type === 'boolean')
+ {
+ startdaten[propName] = !!values && values[propName] === 'true';
+ continue;
+ }
+
+ const liste = sourceItems[spec.source];
+ startdaten[propName] = (values && values[propName])
+ || (liste && liste.length ? liste[0].value : '');
+
+ if (spec.picker === 'dms')
+ {
+ startdaten[propName + FELD_KATEGORIE] = '';
+ startdaten[propName + FELD_DOKUMENT] = '';
+ }
+ }
+
+ editor.windowManager.open(konfigBauen(startdaten));
+ });
+ }
+
+ // Opens the property dialog of an existing marker.
+ function openMarker(editor, node)
+ {
+ const marker = readMarker(node);
+
+ if (marker)
+ openProperties(editor, marker.name, marker.values, node);
+ }
+
+ return {
+ openPicker: openPicker,
+ openMarker: openMarker
+ };
+}
diff --git a/public/js/components/Cms/Form/FieldString.js b/public/js/components/Cms/Form/FieldString.js
new file mode 100644
index 000000000..edaa9c456
--- /dev/null
+++ b/public/js/components/Cms/Form/FieldString.js
@@ -0,0 +1,25 @@
+export default {
+ name: 'FieldString',
+ props: {
+ modelValue: { type: String, default: '' },
+ label: String,
+ required: Boolean,
+ disabled: Boolean,
+ type: { type: String, default: 'text' }
+ },
+ emits: ['update:modelValue'],
+ template: `
+
+
+ {{ label }} *
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Form/FieldWysiwyg.js b/public/js/components/Cms/Form/FieldWysiwyg.js
new file mode 100644
index 000000000..50b38458b
--- /dev/null
+++ b/public/js/components/Cms/Form/FieldWysiwyg.js
@@ -0,0 +1,162 @@
+import { CONTENTCOMPONENT_CLASS, CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
+import { createContentcomponentDialog } from './ContentcomponentDialog.js';
+
+export default {
+ name: 'FieldWysiwyg',
+ props: {
+ modelValue: { type: String, default: '' },
+ label: String,
+ required: Boolean,
+ disabled: Boolean
+ },
+ emits: ['update:modelValue'],
+ data() {
+ return {
+ editor: null,
+ editorId: 'cms-wysiwyg-' + Math.random().toString(36).substring(2, 11)
+ };
+ },
+ methods: {
+ getContent() {
+ return this.editor ? this.editor.getContent() : this.modelValue;
+ },
+
+ // Opens the legacy DMS browser and resolves with the relative URL it posts back.
+ // Used for images and links, where the editor needs the file preview of that
+ // browser. The contentcomponent dialog picks documents from a list instead, without
+ // leaving the dialog. The bridge in cms/tinymce_dms.php sends { typ, url } to
+ // window.opener and closes itself.
+ openDmsBrowser() {
+ const vm = this;
+
+ return new Promise(resolve => {
+ if (vm._dmsListener)
+ window.removeEventListener('message', vm._dmsListener);
+
+ const url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/tinymce_dms.php';
+ // The window name selects the bridge path in cms/tinymce_dms.php and survives
+ // its internal navigation. The legacy admin opens the same page as 'DMS'.
+ window.open(url, 'DMS_CIS4', 'width=1400,height=850,scrollbars=1,resizable=1');
+
+ const onMessage = (event) => {
+ if (event.origin !== window.location.origin) return;
+ if (!event.data || event.data.typ !== 'fhc-dms-selection') return;
+
+ window.removeEventListener('message', onMessage);
+ vm._dmsListener = null;
+ resolve(event.data.url);
+ };
+
+ vm._dmsListener = onMessage;
+ window.addEventListener('message', onMessage);
+ });
+ }
+ },
+ watch: {
+ modelValue(newVal) {
+ if (this.editor && this.editor.getContent() !== newVal) {
+ this.editor.setContent(newVal || '');
+ }
+ }
+ },
+ mounted() {
+ const vm = this;
+
+ // The dialogs live in their own module. This file stays with the editor itself.
+ this.contentcomponentDialog = createContentcomponentDialog({
+ api: this.$api,
+ // The dialog module is no component, so it cannot reach the phrases itself.
+ p: this.$p,
+ onChanged: () => vm.$emit('update:modelValue', vm.editor.getContent())
+ });
+
+ tinymce.init({
+ target: this.$refs.editorArea,
+ height: 500,
+ language: 'de',
+ // valid_elements: '*[*]' permits every tag and attribute. Existing content
+ // contains audio, video and source tags. A stricter setting deletes them silently.
+ valid_elements: '*[*]',
+ // A contentcomponent marker is one atomic block. The editor must not type into it.
+ noneditable_noneditable_class: CONTENTCOMPONENT_CLASS,
+ // The marker renders nothing here, so the editor has to show that it is there.
+ content_style: '.' + CONTENTCOMPONENT_CLASS + '{'
+ + 'display:block;margin:.5rem 0;padding:.5rem;min-height:2.5rem;'
+ + 'border:1px dashed #7a7a7a;background:#f2f2f2;cursor:pointer;}'
+ + '.' + CONTENTCOMPONENT_CLASS + '::before{'
+ + 'content:"Contentcomponent: " attr(' + CONTENTCOMPONENT_ATTR + ');'
+ + 'font:12px monospace;color:#555;}',
+ forced_root_block: '',
+ // No style_formats, and no styleselect in the toolbar below. The six formats
+ // of the legacy admin are gone, and none of them earned a port.
+ //
+ // Three wrote a div, while every stylesheet targets a td: skin/styles/tw.css
+ // declares td.cmscontent, td.menubox and td.teambox. The live content agrees,
+ // it carries those classes on a td about 1400 times and on a div twice.
+ // Tablesorter wrote tablesorter_col_0, which no file in this repository
+ // reads, and skin/style.css.php never serves skin/tablesort.css.
+ //
+ // cmstable and screenshot_boxshadow did work, but only in the legacy
+ // renderer. public/css/Cis4/Cms.css binds its copies to #cms, and CIS4
+ // renders into #fhc-cms-content. CIS4 is the target, so they went too.
+ //
+ // A styling option for editors is a feature of its own. See
+ // docs/cms-darstellung-entwurf.md.
+ plugins: 'code lists link image table fullscreen paste searchreplace charmap directionality visualblocks anchor hr noneditable',
+ toolbar: [
+ 'code | contentcomponent | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | fontsizeselect',
+ 'pastetext | searchreplace | bullist numlist | outdent indent blockquote | undo redo | link unlink anchor image | forecolor backcolor',
+ 'table | hr removeformat visualblocks | subscript superscript | charmap | ltr rtl | fullscreen'
+ ],
+ // The relative form dms.php?id=N is mandatory. The legacy view runs under /cms/
+ // and resolves it directly. The CIS4 view replaces it in CmsLib::getContent
+ // with APP_ROOT . 'cms/dms.php'. An absolute URL breaks both paths.
+ file_picker_callback: (callback, value, meta) => {
+ vm.openDmsBrowser().then(callback);
+ },
+ setup: (editor) => {
+ vm.editor = editor;
+
+ editor.ui.registry.addButton('contentcomponent', {
+ text: vm.$p.t('cms/ccContentcomponent'),
+ tooltip: vm.$p.t('cms/ccDialogTitel'),
+ onAction: () => vm.contentcomponentDialog.openPicker(editor)
+ });
+
+ // A double click on a marker reopens its property dialog.
+ editor.on('dblclick', (event) => {
+ const node = editor.dom.getParent(event.target, '.' + CONTENTCOMPONENT_CLASS);
+
+ if (node)
+ vm.contentcomponentDialog.openMarker(editor, node);
+ });
+
+ editor.on('init', () => {
+ editor.setContent(vm.modelValue || '');
+ if (vm.disabled) editor.mode.set('readonly');
+ });
+ editor.on('change input', () => {
+ vm.$emit('update:modelValue', editor.getContent());
+ });
+ }
+ });
+ },
+ beforeUnmount() {
+ if (this._dmsListener) {
+ window.removeEventListener('message', this._dmsListener);
+ this._dmsListener = null;
+ }
+ if (this.editor && tinymce.get(this.editor.id)) {
+ tinymce.get(this.editor.id).remove();
+ this.editor = null;
+ }
+ },
+ template: `
+
+
+ {{ label }} *
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Form/XsdForm.js b/public/js/components/Cms/Form/XsdForm.js
new file mode 100644
index 000000000..996800936
--- /dev/null
+++ b/public/js/components/Cms/Form/XsdForm.js
@@ -0,0 +1,72 @@
+import FieldString from './FieldString.js';
+import FieldWysiwyg from './FieldWysiwyg.js';
+
+export default {
+ name: 'XsdForm',
+ components: {
+ 'field-string': FieldString,
+ 'field-wysiwyg': FieldWysiwyg
+ },
+ props: {
+ schema: Object,
+ modelValue: Object,
+ disabled: Boolean
+ },
+ emits: ['update:modelValue'],
+ methods: {
+ updateField(name, value) {
+ this.$emit('update:modelValue', { ...this.modelValue, [name]: value });
+ },
+ collectValues() {
+ const vals = { ...this.modelValue };
+ if (!this.schema?.fields) return vals;
+ for (const field of this.schema.fields) {
+ if (field.type === 'wysiwyg') {
+ const ref = this.$refs['wysiwyg-' + field.name];
+ const comp = Array.isArray(ref) ? ref[0] : ref;
+ if (comp?.getContent) vals[field.name] = comp.getContent();
+ }
+ }
+ return vals;
+ }
+ },
+ template: `
+
+
+
+
+
+
+
+ {{ field.name }} *
+
+
+
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Header/ContentHeader.js b/public/js/components/Cms/Header/ContentHeader.js
new file mode 100644
index 000000000..b6132da7d
--- /dev/null
+++ b/public/js/components/Cms/Header/ContentHeader.js
@@ -0,0 +1,180 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+import VersionSelect from './VersionSelect.js';
+import LanguageSelect from './LanguageSelect.js';
+
+export default {
+ name: 'CmsContentHeader',
+ components: {
+ 'version-select': VersionSelect,
+ 'sprache-select': LanguageSelect
+ },
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['select-language-version', 'reload-content-info'],
+ data() {
+ return {
+ titel: '',
+ allSprachen: [],
+ versionDetails: [],
+ // Target of the translation dropdown, used once the list grows past a pair.
+ neueSprache: ''
+ };
+ },
+ computed: {
+ languages() {
+ return this.contentInfo?.languages || [];
+ },
+ versions() {
+ if (!this.contentInfo?.versions || !this.sprache) return [];
+ return this.contentInfo.versions[this.sprache] || [];
+ },
+ availableSprachen() {
+ return this.allSprachen.filter(
+ s => !this.languages.includes(s.sprache)
+ );
+ }
+ },
+ watch: {
+ contentId() {
+ this.loadTitel();
+ this.loadVersionDetails();
+ },
+ sprache() {
+ this.loadTitel();
+ this.loadVersionDetails();
+ },
+ version() {
+ this.loadTitel();
+ }
+ },
+ methods: {
+ loadTitel() {
+ if (this.contentId == null || !this.sprache || !this.version) return;
+ this.$api
+ .call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
+ .then(result => {
+ this.titel = result.data?.titel || '';
+ });
+ },
+
+ loadVersionDetails() {
+ if (this.contentId == null || !this.sprache) {
+ this.versionDetails = [];
+ return;
+ }
+ this.$api
+ .call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
+ .then(result => {
+ this.versionDetails = result.data || [];
+ });
+ },
+
+ loadSprachen() {
+ this.$api
+ .call(ApiCmsAdmin.getSprachen())
+ .then(result => {
+ this.allSprachen = result.data || [];
+ });
+ },
+
+ selectVersion(ver) {
+ this.$emit('select-language-version', this.sprache, ver);
+ },
+
+ selectLanguage(lang) {
+ const versions = this.contentInfo?.versions?.[lang] || [];
+ const maxVersion = versions.length ? Math.max(...versions) : 1;
+ this.$emit('select-language-version', lang, maxVersion);
+ },
+
+ createVersion() {
+ this.$api
+ .call(ApiCmsAdmin.postVersion(this.contentId, this.sprache))
+ .then(result => {
+ const newVersion = result.data.version;
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/neueVersionAngelegt'));
+ this.$fhcAlert.alertInfo(this.$p.t('cms/neueVersionIstUnsichtbar'));
+ this.$emit('reload-content-info');
+ this.$emit('select-language-version', this.sprache, newVersion);
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ createTranslation(newSprache) {
+ if (!newSprache) return;
+ this.neueSprache = '';
+ this.$api
+ .call(ApiCmsAdmin.postTranslation(
+ this.contentId, this.sprache, this.version, newSprache
+ ))
+ .then(() => {
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/uebersetzungAngelegt'));
+ this.$emit('reload-content-info');
+ this.$emit('select-language-version', newSprache, this.version);
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ }
+ },
+ mounted() {
+ this.loadSprachen();
+ this.loadTitel();
+ this.loadVersionDetails();
+ },
+ template: `
+
+
+ {{ $p.t('cms/contentId') }}: {{ contentId }}
+ | {{ $p.t('cms/version') }}: {{ version }}
+ | {{ $p.t('cms/sprache') }}: {{ sprache }}
+ | {{ $p.t('cms/titel') }}: {{ titel }}
+
+
+
+
+
+
+
+ {{ $p.t('cms/neueVersionAnlegen') }}
+
+
+
+
+
+ {{ $p.t('cms/spracheWaehlen') }}
+ {{ s.sprache }}
+
+ {{ $p.t('cms/uebersetzungAnlegen') }}
+
+
+ {{ $p.t('cms/uebersetzungAnlegenIn', [s.sprache]) }}
+
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Header/LanguageSelect.js b/public/js/components/Cms/Header/LanguageSelect.js
new file mode 100644
index 000000000..97935da13
--- /dev/null
+++ b/public/js/components/Cms/Header/LanguageSelect.js
@@ -0,0 +1,36 @@
+export default {
+ name: 'SpracheSelect',
+ props: {
+ languages: { type: Array, default: () => [] },
+ sprache: String
+ },
+ emits: ['select-language'],
+ computed: {
+ // Two languages read faster as a pair of buttons, and that is the case here. Other
+ // institutions run this software with more content languages, and there the row of
+ // buttons pushes the rest of the header off the line.
+ alsAuswahl() {
+ return this.languages.length > 2;
+ }
+ },
+ template: `
+
+ {{ lang }}
+
+
+ {{ lang }}
+
+ `
+};
diff --git a/public/js/components/Cms/Header/VersionSelect.js b/public/js/components/Cms/Header/VersionSelect.js
new file mode 100644
index 000000000..bd4396acd
--- /dev/null
+++ b/public/js/components/Cms/Header/VersionSelect.js
@@ -0,0 +1,81 @@
+// Versions shown on each side of the open one.
+const WINDOW = 3;
+
+export default {
+ name: 'VersionSelect',
+ props: {
+ versions: { type: Array, default: () => [] },
+ versionDetails: { type: Array, default: () => [] },
+ version: Number
+ },
+ emits: ['select-version'],
+ computed: {
+ // A content can carry dozens of versions, and one button each breaks the header.
+ // Show the open version with three neighbours on each side. The first and the last
+ // stay reachable in one click, and a gap marks what the window leaves out.
+ // Below the threshold the window would hide nothing, so show every version.
+ items() {
+ const all = this.versions;
+ const active = all.indexOf(this.version);
+
+ if (active === -1 || all.length <= WINDOW * 2 + 3)
+ return all.map(value => ({ type: 'version', value }));
+
+ const from = Math.max(0, active - WINDOW);
+ const to = Math.min(all.length - 1, active + WINDOW);
+ const items = [];
+
+ if (from > 0) {
+ items.push({ type: 'version', value: all[0] });
+ // One version between the first and the window needs no gap marker.
+ if (from > 1) items.push({ type: 'gap', count: from - 1 });
+ }
+
+ for (let i = from; i <= to; i++)
+ items.push({ type: 'version', value: all[i] });
+
+ if (to < all.length - 1) {
+ if (to < all.length - 2)
+ items.push({ type: 'gap', count: all.length - 2 - to });
+ items.push({ type: 'version', value: all[all.length - 1] });
+ }
+
+ return items;
+ },
+ sichtbarMap() {
+ const map = {};
+ for (const v of this.versionDetails) {
+ map[v.version] = v.sichtbar;
+ }
+ return map;
+ }
+ },
+ methods: {
+ isSichtbar(ver) {
+ return this.sichtbarMap[ver] !== false;
+ }
+ },
+ template: `
+
+
+ {{ item.value }}
+ …
+
+
+ `
+};
diff --git a/public/js/components/Cms/Preview.js b/public/js/components/Cms/Preview.js
new file mode 100644
index 000000000..4c0e13299
--- /dev/null
+++ b/public/js/components/Cms/Preview.js
@@ -0,0 +1,308 @@
+// Safety net for the spinner.
+const CIS4_LOAD_TIMEOUT = 10000;
+
+// Cis.js reads window.innerWidth and switches to its mobile layout below 767px. In a frame
+// that is the width of the frame, so these widths drive the real layout and not a zoom.
+// Its resize listener reacts on its own, which means no reload and no second long wait.
+const CIS4_WIDTHS = {
+ mobile: 375,
+ desktop: null
+};
+
+export default {
+ name: 'CmsPreview',
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ templateKurzbz: String,
+ // The legacy renderer runs no Vue, so it draws nothing for a contentcomponent
+ // marker. The CIS4 view mounts them, which is the whole point of switching.
+ hasContentcomponents: Boolean,
+ // False for a template whose xslt_xhtml_c4 is a copy of the legacy stylesheet.
+ // Such a template emits a whole HTML document, and the CIS4 renderer writes that
+ // into a div, so the layout is lost. Defaults true, so an older payload warns
+ // about nothing.
+ hasCis4Stylesheet: { type: Boolean, default: true },
+ sichtbar: Boolean
+ },
+ data() {
+ // The legacy renderer stays the default, so nothing changes until an editor asks
+ // for the other one. The choice survives a page switch, like the menu switch.
+ const stored = localStorage.getItem('cms/previewMode');
+ const mode = (stored === 'legacy' || stored === 'cis4') ? stored : 'legacy';
+ const width = localStorage.getItem('cms/previewWidth');
+ return {
+ mode: mode,
+ // True while the split handle is dragged. See onSplitterDown.
+ dragging: false,
+ cis4Width: CIS4_WIDTHS.hasOwnProperty(width) ? width : 'desktop',
+ // The CIS4 shell pulls its whole stack before it shows anything, which takes a
+ // while. Start covered when the frame is the one on screen.
+ cis4Loading: mode === 'cis4'
+ };
+ },
+ watch: {
+ mode(value) {
+ if (value === 'cis4') this.startCis4Load();
+ else this.stopCis4Poll();
+ },
+ // A new content, language or version reloads the frame.
+ cis4PreviewUrl() {
+ if (this.mode === 'cis4') this.startCis4Load();
+ }
+ },
+ computed: {
+ // The preview uses the legacy renderer cms/content.php. It reads xslt_xhtml, which
+ // holds a complete HTML document for all eleven templates. The CIS4 route reads
+ // xslt_xhtml_c4, where four templates hold a whole document instead of a fragment.
+ previewUrl() {
+ return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ + 'cms/content.php?content_id=' + this.contentId
+ + '&version=' + this.version
+ + '&sprache=' + this.sprache
+ + '&sichtbar=';
+ },
+ // The CIS4 route takes content_id only. Its Vue route is
+ // /CisVue/Cms/Content/:content_id, and extra segments hit the catch-all, which
+ // redirects to the dashboard. CIS4 picks the version itself and the language from
+ // the viewer profile.
+ cis4Url() {
+ return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ + FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ + '/CisVue/Cms/content/' + this.contentId;
+ },
+ // The second preview renders the content the way CIS4 does: the route loads the
+ // CIS4 shell, which brings its stylesheets, its Vue app and the contentcomponents.
+ // CISVUE-Header sets the body class in-frame by itself, and Cis.css hides the CIS4
+ // header for it, so the frame shows the content and no navigation.
+ // version travels as a route segment, because Cms/Content.js already declares that
+ // prop and the route passes props. sprache and preview travel as query parameters,
+ // because neither is a prop there.
+ cis4PreviewUrl() {
+ let url = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ + FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ + '/CisVue/Cms/content/' + this.contentId;
+
+ if (this.version !== null)
+ url += '/' + this.version;
+
+ url += '?preview=1';
+
+ if (this.sprache)
+ url += '&sprache=' + encodeURIComponent(this.sprache);
+
+ return url;
+ },
+ // The overlay sits on the wrapper, so the wrapper carries the width. Otherwise the
+ // spinner would cover the whole pane while the frame is only 375px wide.
+ cis4WrapStyle() {
+ const px = CIS4_WIDTHS[this.cis4Width];
+ // content-box on purpose: the frame reads its own window.innerWidth, and with
+ // the usual border-box the border would eat two pixels. The preset is meant
+ // to be the width the CIS4 app sees, so it must not depend on the frame.
+ return px === null
+ ? ''
+ : 'width: ' + px + 'px; box-sizing: content-box; max-width: 100%;'
+ + ' margin: 0 auto;';
+ },
+ // The redirect template navigates away with JavaScript. In an iframe this can close
+ // the admin page. Therefore this case uses a link and not an iframe.
+ isRedirect() {
+ return this.templateKurzbz === 'redirect';
+ }
+ },
+ methods: {
+ selectMode(value) {
+ localStorage.setItem('cms/previewMode', value);
+ this.mode = value;
+ },
+
+ // The split handle sits right next to the frame. As soon as the pointer crosses
+ // into the frame, the frame document takes the mousemove and the mouseup, the
+ // listeners HorizontalSplit put on the parent window never fire, and the drag
+ // stops halfway. Turning pointer events off in the frame for the length of the
+ // drag lets those events through to the page underneath.
+ //
+ // Capture phase on purpose: HorizontalSplit.dragStart calls stopPropagation, so a
+ // listener that waits for the event to bubble back up never sees it at all.
+ onSplitterDown(event) {
+ const target = event.target;
+ if (target && target.closest && target.closest('.horizontalsplitter'))
+ this.dragging = true;
+ },
+
+ onSplitterUp() {
+ this.dragging = false;
+ },
+
+ selectWidth(value) {
+ localStorage.setItem('cms/previewWidth', value);
+ this.cis4Width = value;
+
+ // A frame resized by its parent gets no resize event of its own, so Cis.js
+ // would keep the window width it read at load and stay on that layout. Same
+ // origin, so tell the frame. That spares a reload of the whole CIS4 stack.
+ this.$nextTick(() => {
+ try {
+ const frame = this.$refs.cis4Frame;
+ const win = frame && frame.contentWindow;
+ if (win) win.dispatchEvent(new win.Event('resize'));
+ } catch (e) {
+ // Not reachable. The frame keeps the layout it loaded with.
+ }
+ });
+ },
+
+ startCis4Load() {
+ this.stopCis4Poll();
+ this.cis4Loading = true;
+ },
+
+ // The frame fires load once the CIS4 shell is there, but the shell then fetches the
+ // content over the API, so the load event alone clears the spinner too early and
+ // leaves the placeholder text on screen. The frame is same origin, so wait for the
+ // element the CIS4 renderer writes once it holds the content.
+ onCis4Load() {
+ this.stopCis4Poll();
+ const started = Date.now();
+
+ this.cis4Poll = setInterval(() => {
+ let done = false;
+
+ try {
+ const doc = this.$refs.cis4Frame && this.$refs.cis4Frame.contentDocument;
+ done = !!(doc && doc.getElementById('fhc-cms-content'));
+ } catch (e) {
+ // Not readable after all. Stop waiting rather than spin forever.
+ done = true;
+ }
+
+ if (done || Date.now() - started > CIS4_LOAD_TIMEOUT) {
+ this.stopCis4Poll();
+ this.cis4Loading = false;
+ }
+ }, 120);
+ },
+
+ stopCis4Poll() {
+ if (this.cis4Poll) {
+ clearInterval(this.cis4Poll);
+ this.cis4Poll = null;
+ }
+ },
+
+ neuLaden() {
+ if (this.$refs.previewFrame) {
+ this.$refs.previewFrame.src = this.previewUrl + '&t=' + Date.now();
+ }
+ if (this.$refs.cis4Frame) {
+ this.startCis4Load();
+ this.$refs.cis4Frame.src = this.cis4PreviewUrl + '&t=' + Date.now();
+ }
+ }
+ },
+ mounted() {
+ // On the window, not the document: HorizontalSplit ends the drag from a listener
+ // it puts on the window, and a mouseup aimed at the window never reaches a
+ // listener on the document. The window is the first node of the capture path, so
+ // it sees everything the document would and that case as well.
+ window.addEventListener('mousedown', this.onSplitterDown, true);
+ window.addEventListener('mouseup', this.onSplitterUp, true);
+ // Backstop. A missed mouseup would leave the frame unclickable.
+ window.addEventListener('blur', this.onSplitterUp);
+ },
+ beforeUnmount() {
+ window.removeEventListener('mousedown', this.onSplitterDown, true);
+ window.removeEventListener('mouseup', this.onSplitterUp, true);
+ window.removeEventListener('blur', this.onSplitterUp);
+ this.stopCis4Poll();
+ },
+ template: `
+
+ `
+};
diff --git a/public/js/components/Cms/Tabs/Clickstats.js b/public/js/components/Cms/Tabs/Clickstats.js
new file mode 100644
index 000000000..63a203202
--- /dev/null
+++ b/public/js/components/Cms/Tabs/Clickstats.js
@@ -0,0 +1,153 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+
+export default {
+ name: 'CmsClickstats',
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['select-content'],
+ data() {
+ return {
+ own: null,
+ ranked: [],
+ // The ranking stays absent until the editor asks for it, so an empty table and
+ // a table that was never fetched stay apart.
+ rankingLoaded: false,
+ since: null,
+ months: 12,
+ loadingOwn: false,
+ loadingRanking: false
+ };
+ },
+ watch: {
+ contentId() { this.loadOwn(); },
+ sprache() { this.loadOwn(); }
+ },
+ methods: {
+ // One counting query. Cheap enough to run whenever the tab or the period opens.
+ loadOwn() {
+ if (this.contentId == null) return;
+
+ this.loadingOwn = true;
+ this.$api
+ .call(ApiCmsAdmin.getClickCount(this.contentId, this.months))
+ .then(result => {
+ this.own = result.data.own;
+ this.since = result.data.since;
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.loadingOwn = false; });
+ },
+
+ // Groups the whole log, which takes seconds. Only on request.
+ loadRanking() {
+ this.loadingRanking = true;
+ this.$api
+ .call(ApiCmsAdmin.getClickRanking(this.months, this.sprache))
+ .then(result => {
+ this.ranked = result.data.ranked || [];
+ this.since = result.data.since;
+ this.rankingLoaded = true;
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.loadingRanking = false; });
+ },
+
+ // A new period always refreshes the cheap number. The ranking follows only once it
+ // is on screen, so changing the period never costs the long wait unasked.
+ onPeriodChange() {
+ this.loadOwn();
+ if (this.rankingLoaded) this.loadRanking();
+ },
+
+ // since arrives as an SQL timestamp. Format it the way the other tabs do.
+ formatDate(value) {
+ if (!value) return '';
+ const dt = luxon.DateTime.fromSQL(value);
+ return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value;
+ },
+
+ isCurrent(row) {
+ return row.content_id === this.contentId;
+ },
+
+ onContentClick(content_id) {
+ this.$emit('select-content', content_id);
+ }
+ },
+ created() {
+ this.monthOptions = [3, 6, 12, 24, 0];
+ },
+ mounted() {
+ this.loadOwn();
+ },
+ template: `
+
+
+ {{ $p.t('cms/zeitraum') }}
+
+
+ {{ m ? $p.t('cms/letzteNMonate', [m]) : $p.t('cms/gesamterZeitraum') }}
+
+
+
+
+
+
+ {{ since
+ ? $p.t('cms/klicksSeit', [formatDate(since)])
+ : $p.t('cms/klicksGesamt') }}
+
+
+
+
{{ $p.t('cms/aufrufeDiesesEintrags') }}
+ {{ own === null ? '-' : own }}
+
+
+
{{ $p.t('cms/meistAufgerufeneEintraege') }}
+
+
+
+ {{ $p.t('cms/alleKlickstatistikenLaden') }}
+
+
+
+
+
+ {{ $p.t('cms/keineAufrufeImZeitraum') }}
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tabs/Content.js b/public/js/components/Cms/Tabs/Content.js
new file mode 100644
index 000000000..72441b763
--- /dev/null
+++ b/public/js/components/Cms/Tabs/Content.js
@@ -0,0 +1,199 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+import XsdForm from '../Form/XsdForm.js';
+import CmsPreview from '../Preview.js';
+import { CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
+
+export default {
+ name: 'CmsContent',
+ components: {
+ 'xsd-form': XsdForm,
+ 'cms-preview': CmsPreview
+ },
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['reload-content-info', 'refresh-tree-node', 'select-content'],
+ data() {
+ return {
+ schema: null,
+ values: {},
+ sperre: null,
+ sichtbar: true,
+ loading: false,
+ saving: false
+ };
+ },
+ computed: {
+ // The legacy renderer runs no Vue and draws nothing for a marker. The preview says
+ // so, but only while the legacy view is the one on screen.
+ hasContentcomponents() {
+ return Object.values(this.values || {}).some(
+ value => typeof value === 'string' && value.indexOf(CONTENTCOMPONENT_ATTR) !== -1
+ );
+ },
+ // The redirect template carries a URL, and its preview opens in a new window
+ // instead of an iframe. So without the lock nothing on this tab shows the values.
+ // Read only fields spare the editor a lock that blocks the content for 24 hours.
+ isRedirect() {
+ return this.contentInfo?.template_kurzbz === 'redirect';
+ },
+ lockState() {
+ if (!this.sperre || this.sperre.gesperrt_uid === null) return 'free';
+ if (this.sperre.own) return 'own';
+ if (this.sperre.expired) return 'expired';
+ return 'foreign';
+ },
+ lockInfo() {
+ if (!this.sperre || this.sperre.gesperrt_uid === null) return '';
+
+ const start = this.formatDateTime(this.sperre.start);
+ const ende = this.formatDateTime(this.sperre.expires);
+
+ if (this.sperre.own)
+ return this.$p.t('cms/eigeneSperre', [start, ende]);
+
+ const uid = this.sperre.gesperrt_uid;
+ if (this.sperre.expired)
+ return this.$p.t('cms/gesperrtVonSeitAbgelaufen', [uid, start, ende]);
+ return this.$p.t('cms/gesperrtVonSeit', [uid, start, ende]);
+ }
+ },
+ watch: {
+ contentId() { this.loadFormData(); },
+ sprache() { this.loadFormData(); },
+ version() { this.loadFormData(); }
+ },
+ methods: {
+ formatDateTime(dateStr) {
+ if (!dateStr) return this.$p.t('cms/unbekannt');
+ const dt = luxon.DateTime.fromSQL(dateStr);
+ return dt.isValid ? dt.toFormat('dd.MM.yyyy HH:mm') : dateStr;
+ },
+
+ loadFormData() {
+ if (this.contentId == null || !this.sprache || !this.version) return;
+ this.loading = true;
+ Promise.all([
+ this.$api.call(ApiCmsAdmin.getFormData(this.contentId, this.sprache, this.version)),
+ this.$api.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
+ ])
+ .then(([formResult, spracheResult]) => {
+ const d = formResult.data;
+ this.schema = d.schema;
+ this.values = d.values || {};
+ this.sperre = d.sperre;
+ this.sichtbar = !!spracheResult.data.sichtbar;
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.loading = false; });
+ },
+
+ lock() {
+ this.$api
+ .call(ApiCmsAdmin.postLock(this.sperre.contentsprache_id))
+ .then(() => { this.loadFormData(); })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ unlock() {
+ this.$api
+ .call(ApiCmsAdmin.deleteLock(this.sperre.contentsprache_id))
+ .then(() => { this.loadFormData(); })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ forceUnlock() {
+ this.$api
+ .call(ApiCmsAdmin.deleteLockForced(this.sperre.contentsprache_id))
+ .then(() => { this.loadFormData(); })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ save() {
+ const freshValues = this.$refs.xsdForm
+ ? this.$refs.xsdForm.collectValues()
+ : this.values;
+ this.saving = true;
+ this.$api
+ .call(ApiCmsAdmin.putFormData(
+ this.contentId, this.sprache, this.version, freshValues
+ ))
+ .then(() => {
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
+ // The form holds the title, and the tree shows it.
+ this.$emit('refresh-tree-node', this.contentId);
+ if (this.$refs.preview) this.$refs.preview.neuLaden();
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.saving = false; });
+ }
+ },
+ mounted() {
+ this.loadFormData();
+ },
+ template: `
+
+
+
+ {{ lockInfo }}
+
+
+
+
+ {{ $p.t('cms/zurBearbeitungSperren') }}
+
+
+
+
+
+ {{ $p.t('cms/sperreFreigeben') }}
+
+
+
+ {{ $p.t('cms/speichern') }}
+
+
+
+
+
+ {{ $p.t('cms/sperreUebernehmen') }}
+
+
+
+
+
+ {{ $p.t('cms/freigabeErzwingen') }}
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tabs/Delete.js b/public/js/components/Cms/Tabs/Delete.js
new file mode 100644
index 000000000..2bbdbb5a7
--- /dev/null
+++ b/public/js/components/Cms/Tabs/Delete.js
@@ -0,0 +1,154 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+
+export default {
+ name: 'CmsDelete',
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['reload-content-info', 'reload-tree', 'refresh-tree-node', 'select-content'],
+ data() {
+ return {
+ usages: [],
+ loadingUsage: true
+ };
+ },
+ computed: {
+ hasRestrict() {
+ return this.usages.some(u => u.table !== 'campus.tbl_infoscreen_content');
+ },
+ // forward defaultLanguage prop to tabs. Using 'German' as fallback.
+ isLastVersion() {
+ if (!this.contentInfo?.versions || !this.sprache) return false;
+ const defaultLang = 'German';
+ const versions = this.contentInfo.versions[this.sprache];
+ return this.sprache === defaultLang && versions && versions.length === 1;
+ },
+ totalVersionCount() {
+ if (!this.contentInfo?.versions) return 0;
+ return Object.values(this.contentInfo.versions)
+ .reduce((sum, vers) => sum + vers.length, 0);
+ }
+ },
+ watch: {
+ contentId() { this.loadUsage(); }
+ },
+ methods: {
+ loadUsage() {
+ if (this.contentId == null) return;
+ this.loadingUsage = true;
+ this.$api
+ .call(ApiCmsAdmin.getUsage(this.contentId))
+ .then(result => {
+ this.usages = result.data || [];
+ })
+ .finally(() => { this.loadingUsage = false; });
+ },
+
+ deleteVersion() {
+ this.$fhcAlert.confirm({
+ message: this.$p.t('cms/versionLoeschenBestaetigen'),
+ acceptLabel: this.$p.t('ui/loeschen'),
+ acceptClass: 'p-button-danger'
+ }).then(confirmed => {
+ if (!confirmed) return;
+ return this.$api.call(
+ ApiCmsAdmin.deleteContentsprache(this.contentId, this.sprache, this.version)
+ );
+ }).then(result => {
+ if (!result) return;
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/versionGeloescht'));
+ this.$emit('reload-content-info');
+ // The tree shows the title of the newest version, so the label can change.
+ this.$emit('refresh-tree-node', this.contentId);
+ }).catch(this.$fhcAlert.handleSystemError);
+ },
+
+ deleteContent() {
+ this.$fhcAlert.confirm({
+ message: this.$p.t('cms/contentLoeschenBestaetigen', [
+ this.contentInfo.languages.length,
+ this.totalVersionCount
+ ]),
+ acceptLabel: this.$p.t('ui/loeschen'),
+ acceptClass: 'p-button-danger'
+ }).then(confirmed => {
+ if (!confirmed) return;
+ return this.$api.call(ApiCmsAdmin.deleteContent(this.contentId));
+ }).then(result => {
+ if (!result) return;
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/contentGeloescht'));
+ // Children of the deleted entry can reappear under another parent, so the
+ // whole tree has to be rebuilt here.
+ this.$emit('reload-tree');
+ this.$nextTick(() => {
+ this.$emit('select-content', null);
+ });
+ }).catch(this.$fhcAlert.handleSystemError);
+ }
+ },
+ mounted() {
+ this.loadUsage();
+ },
+ // Five tables prevent the deletion with ON DELETE RESTRICT. tbl_infoscreen_content
+ // deletes its own rows with CASCADE. The legacy code shows neither case and hides the
+ // error. See Q5 in the contract.
+ template: `
+
+
{{ $p.t('cms/dieseVersionLoeschen') }}
+
+
+ {{ $p.t('cms/letzteVersionNichtLoeschbar') }}
+
+
+
+ {{ $p.t('cms/dieseVersionLoeschen') }}
+
+
+
+
+
+
{{ $p.t('cms/ganzenContentLoeschen') }}
+
+
+
+
+ {{ $p.t('cms/keineVerwendungGefunden') }}
+
+
+
+ {{ $p.t('cms/contentWirdVerwendetIn') }}
+
+
+
+
+ {{ u.label || '—' }}
+
+ {{ u.table }}
+
+
+ {{ $p.t('cms/wirdMitgeloescht') }}
+
+
+ {{ $p.t('cms/verhindertLoeschen') }}
+
+
+
+
+
+
+
+ {{ $p.t('cms/ganzenContentLoeschen') }}
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tabs/Hierarchy.js b/public/js/components/Cms/Tabs/Hierarchy.js
new file mode 100644
index 000000000..af14943f3
--- /dev/null
+++ b/public/js/components/Cms/Tabs/Hierarchy.js
@@ -0,0 +1,332 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+
+export default {
+ name: 'CmsHierarchy',
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['reload-content-info', 'reload-tree', 'refresh-tree-node', 'select-content'],
+ data() {
+ return {
+ parents: [],
+ childs: [],
+ possibleChilds: [],
+ selectedChild: '',
+ dragIndex: null,
+ orderBefore: null,
+ dropped: false,
+ savingOrder: false
+ };
+ },
+ computed: {
+ // tbl_contentchild has no unique index. The server permits a duplicate assignment.
+ // Therefore the select list hides the children that are already assigned.
+ availableChilds() {
+ const assigned = new Set(this.childs.map(c => c.child_content_id));
+ return this.possibleChilds.filter(c => !assigned.has(c.content_id));
+ }
+ },
+ watch: {
+ contentId() { this.loadAll(); },
+ sprache() { this.loadAll(); }
+ },
+ methods: {
+ loadAll() {
+ this.loadParents();
+ this.loadChilds();
+ this.loadPossibleChilds();
+ },
+
+ loadParents() {
+ if (this.contentId == null || !this.sprache) return;
+ this.$api
+ .call(ApiCmsAdmin.getParents(this.contentId, this.sprache))
+ .then(result => { this.parents = result.data || []; })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ loadChilds() {
+ if (this.contentId == null || !this.sprache) return;
+ this.$api
+ .call(ApiCmsAdmin.getChilds(this.contentId, this.sprache))
+ .then(result => { this.childs = result.data || []; })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ loadPossibleChilds() {
+ if (this.contentId == null || !this.sprache) return;
+ this.$api
+ .call(ApiCmsAdmin.getPossibleChilds(this.contentId, this.sprache))
+ .then(result => {
+ this.possibleChilds = result.data || [];
+ this.selectedChild = '';
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ childLabel(c) {
+ const titel = c.titel || this.$p.t('cms/ohneTitel');
+ return titel + ' (' + c.content_id + ')';
+ },
+
+ async addChild() {
+ if (!this.selectedChild) return;
+ try {
+ await this.$api.call(
+ ApiCmsAdmin.postChild(this.contentId, this.selectedChild)
+ );
+ this.loadAll();
+ // Only this branch changes, so refresh this branch.
+ this.$emit('refresh-tree-node', this.contentId);
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/childHinzugefuegt'));
+ } catch (e) {
+ this.$fhcAlert.handleSystemError(e);
+ }
+ },
+
+ async removeChild(contentchild_id) {
+ const confirmed = await this.$fhcAlert.confirm({
+ message: this.$p.t('cms/zuordnungEntfernenBestaetigen')
+ });
+ if (!confirmed) return;
+ try {
+ await this.$api.call(ApiCmsAdmin.deleteChild(contentchild_id));
+ this.loadAll();
+ this.$emit('refresh-tree-node', this.contentId);
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/childEntfernt'));
+ } catch (e) {
+ this.$fhcAlert.handleSystemError(e);
+ }
+ },
+
+ // Detaches this content from one parent. That is the same row as a child
+ // assignment, seen from the other end, so it uses the same endpoint.
+ async removeParent(contentchild_id) {
+ const confirmed = await this.$fhcAlert.confirm({
+ message: this.$p.t('cms/zuordnungEntfernenBestaetigen')
+ });
+ if (!confirmed) return;
+ try {
+ await this.$api.call(ApiCmsAdmin.deleteChild(contentchild_id));
+ this.loadParents();
+ // The content leaves one branch and may become a root, so rebuild the tree.
+ this.$emit('reload-tree');
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/childEntfernt'));
+ } catch (e) {
+ this.$fhcAlert.handleSystemError(e);
+ }
+ },
+
+ async moveSort(contentchild_id, direction) {
+ try {
+ await this.$api.call(
+ ApiCmsAdmin.putChildSort(contentchild_id, direction)
+ );
+ this.loadChilds();
+ this.$emit('refresh-tree-node', this.contentId);
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/sortierungGespeichert'));
+ } catch (e) {
+ this.$fhcAlert.handleSystemError(e);
+ }
+ },
+
+ // --- Drag and drop ---
+ // The dragged row follows the cursor: every row it enters swaps places with it.
+ // The drop then only has to write the order that already stands on screen.
+
+ onDragStart(index, event) {
+ this.dragIndex = index;
+ this.dropped = false;
+ this.orderBefore = this.childs.map(c => c.contentchild_id);
+ event.dataTransfer.effectAllowed = 'move';
+ // Firefox starts no drag without a payload.
+ event.dataTransfer.setData('text/plain', String(index));
+ },
+
+ onDragEnter(index) {
+ if (this.dragIndex === null || this.dragIndex === index) return;
+ const moved = this.childs.splice(this.dragIndex, 1)[0];
+ this.childs.splice(index, 0, moved);
+ this.dragIndex = index;
+ },
+
+ onDrop() {
+ this.dropped = true;
+ },
+
+ onDragEnd() {
+ if (this.dragIndex === null) return;
+
+ const before = this.orderBefore;
+ this.dragIndex = null;
+ this.orderBefore = null;
+
+ // Escape cancels the drag. Put the rows back.
+ if (!this.dropped) {
+ this.applyOrder(before);
+ return;
+ }
+ if (this.orderDiffers(before)) this.saveOrder(before);
+ },
+
+ applyOrder(contentchild_ids) {
+ if (!contentchild_ids) return;
+ const byId = new Map(this.childs.map(c => [c.contentchild_id, c]));
+ this.childs = contentchild_ids.map(id => byId.get(id)).filter(c => c);
+ },
+
+ orderDiffers(contentchild_ids) {
+ if (!contentchild_ids) return false;
+ return this.childs.some((c, i) => c.contentchild_id !== contentchild_ids[i]);
+ },
+
+ // One request for the whole order. A child moves over any distance, and the arrow
+ // endpoint swaps two neighbours, so it would need one request per position.
+ async saveOrder(before) {
+ this.savingOrder = true;
+ try {
+ await this.$api.call(ApiCmsAdmin.putChildOrder(
+ this.contentId,
+ this.childs.map(c => c.contentchild_id)
+ ));
+ this.loadChilds();
+ this.$emit('refresh-tree-node', this.contentId);
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/sortierungGespeichert'));
+ } catch (e) {
+ this.applyOrder(before);
+ this.$fhcAlert.handleSystemError(e);
+ } finally {
+ this.savingOrder = false;
+ }
+ },
+
+ onContentClick(content_id) {
+ this.$emit('select-content', content_id);
+ },
+
+ isFirst(index) {
+ return index === 0;
+ },
+
+ isLast(index) {
+ return index === this.childs.length - 1;
+ }
+ },
+ mounted() {
+ this.loadAll();
+ },
+ template: `
+
+
{{ $p.t('cms/folgendeEintraegeSindUebergeordnet') }}
+
+
+
+
+ {{ $p.t('cms/keineUebergeordnetenEintraege') }}
+
+
+
{{ $p.t('cms/folgendeEintraegeSindUntergeordnet') }}
+
+
+
+
+ {{ $p.t('cms/ziehenZumSortieren') }}
+
+
+
+
+ {{ $p.t('cms/sortierung') }}
+ ID
+ {{ $p.t('cms/titel') }}
+
+
+
+
+
+
+
+ {{ idx + 1 }}
+
+
+
+
+
+
+
+ {{ child.child_content_id }}
+
+
+ {{ child.titel || $p.t('cms/ohneTitel') }}
+
+
+
+
+ {{ $p.t('cms/entfernen') }}
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/keineUntereintraege') }}
+
+
+
+
+
+
+ {{ childLabel(c) }}
+
+
+
+ {{ $p.t('cms/hinzufuegen') }}
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tabs/History.js b/public/js/components/Cms/Tabs/History.js
new file mode 100644
index 000000000..46809f1e6
--- /dev/null
+++ b/public/js/components/Cms/Tabs/History.js
@@ -0,0 +1,65 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+
+export default {
+ name: 'CmsHistory',
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['reload-content-info', 'reload-tree', 'select-content'],
+ data() {
+ return {
+ versions: []
+ };
+ },
+ watch: {
+ contentId() { this.loadVersions(); },
+ sprache() { this.loadVersions(); }
+ },
+ methods: {
+ loadVersions() {
+ if (this.contentId == null || !this.sprache) return;
+ this.$api
+ .call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
+ .then(result => {
+ this.versions = result.data || [];
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ formatDate(dateStr) {
+ if (!dateStr) return '';
+ try {
+ const dt = luxon.DateTime.fromSQL(dateStr);
+ return dt.isValid ? dt.toFormat('dd.MM.yyyy') : dateStr;
+ } catch (e) {
+ return dateStr;
+ }
+ }
+ },
+ mounted() {
+ this.loadVersions();
+ },
+ template: `
+
+
{{ $p.t('cms/versionen') }}
+
+
+
+ {{ $p.t('cms/version') }} {{ v.version }}
+
+ {{ $p.t('cms/unsichtbar') }}
+
+
+
{{ $p.t('cms/erstelltAmVon', [formatDate(v.insertamum), v.insertvon]) }}
+
+ {{ $p.t('cms/letzteAenderungVonAm', [formatDate(v.updateamum), v.updatevon]) }}
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tabs/Permissions.js b/public/js/components/Cms/Tabs/Permissions.js
new file mode 100644
index 000000000..297bee597
--- /dev/null
+++ b/public/js/components/Cms/Tabs/Permissions.js
@@ -0,0 +1,143 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+
+export default {
+ name: 'CmsPermissions',
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['reload-content-info', 'patch-tree-node', 'select-content'],
+ data() {
+ return {
+ gruppen: [],
+ allGruppen: [],
+ selectedGruppe: '',
+ allGruppenLoaded: false
+ };
+ },
+ computed: {
+ availableGruppen() {
+ const assigned = new Set(this.gruppen.map(g => g.gruppe_kurzbz));
+ return this.allGruppen.filter(g => !assigned.has(g.gruppe_kurzbz));
+ }
+ },
+ watch: {
+ contentId() { this.loadGruppen(); }
+ },
+ methods: {
+ loadGruppen() {
+ if (this.contentId == null) return Promise.resolve();
+ return this.$api
+ .call(ApiCmsAdmin.getGruppen(this.contentId))
+ .then(result => {
+ this.gruppen = result.data || [];
+ this.selectedGruppe = '';
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ // A group only draws the lock icon. It never moves a node, so the tree needs no
+ // request here.
+ patchTree() {
+ this.$emit('patch-tree-node', {
+ content_id: this.contentId,
+ patch: { groups: this.gruppen.map(g => g.gruppe_kurzbz) }
+ });
+ },
+
+ loadAllGruppen() {
+ if (this.allGruppenLoaded) return;
+ this.$api
+ .call(ApiCmsAdmin.getAllGruppen())
+ .then(result => {
+ this.allGruppen = result.data || [];
+ this.allGruppenLoaded = true;
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ async addGruppe() {
+ if (!this.selectedGruppe) return;
+ try {
+ await this.$api.call(
+ ApiCmsAdmin.postGruppe(this.contentId, this.selectedGruppe)
+ );
+ await this.loadGruppen();
+ this.patchTree();
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeHinzugefuegt'));
+ } catch (e) {
+ this.$fhcAlert.handleSystemError(e);
+ }
+ },
+
+ async removeGruppe(gruppe_kurzbz) {
+ if (this.gruppen.length <= 1) {
+ const confirmed = await this.$fhcAlert.confirm({
+ message: this.$p.t('cms/letzteGruppeEntfernenBestaetigen')
+ });
+ if (!confirmed) return;
+ }
+ try {
+ await this.$api.call(
+ ApiCmsAdmin.deleteGruppe(this.contentId, gruppe_kurzbz)
+ );
+ await this.loadGruppen();
+ this.patchTree();
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeEntfernt'));
+ } catch (e) {
+ this.$fhcAlert.handleSystemError(e);
+ }
+ }
+ },
+ mounted() {
+ this.loadAllGruppen();
+ this.loadGruppen();
+ },
+ template: `
+
+
+ {{ $p.t('cms/folgendeGruppenDuerfenAnsehen') }}
+
+
+
+ {{ $p.t('cms/gruppe') }}
+ {{ $p.t('cms/bezeichnung') }}
+
+
+
+
+
+ {{ g.gruppe_kurzbz }}
+ {{ g.bezeichnung }}
+
+
+ {{ $p.t('cms/entfernen') }}
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/seiteDarfVonAllenAngezeigtWerden') }}
+
+
+
+
+
+
+ {{ g.gruppe_kurzbz }} — {{ g.bezeichnung }}
+
+
+
+ {{ $p.t('cms/hinzufuegen') }}
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tabs/Properties.js b/public/js/components/Cms/Tabs/Properties.js
new file mode 100644
index 000000000..2d88750d2
--- /dev/null
+++ b/public/js/components/Cms/Tabs/Properties.js
@@ -0,0 +1,185 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+
+export default {
+ name: 'CmsProperties',
+ props: {
+ contentId: Number,
+ sprache: String,
+ version: Number,
+ contentInfo: Object
+ },
+ emits: ['reload-content-info', 'refresh-tree-node', 'select-content'],
+ data() {
+ return {
+ titel: '',
+ sichtbar: false,
+ template_kurzbz: '',
+ oe_kurzbz: '',
+ aktiv: false,
+ menu_open: false,
+ beschreibung: '',
+ templates: [],
+ organisationseinheiten: [],
+ templateChanged: false,
+ saving: false
+ };
+ },
+ watch: {
+ contentId() { this.loadData(); },
+ sprache() { this.loadData(); },
+ version() { this.loadData(); },
+ contentInfo() { this.loadContentFields(); }
+ },
+ methods: {
+ loadData() {
+ this.templateChanged = false;
+ if (this.contentId == null || !this.sprache || !this.version) return;
+
+ this.$api
+ .call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
+ .then(result => {
+ const d = result.data;
+ this.titel = d.titel || '';
+ this.sichtbar = !!d.sichtbar;
+ });
+
+ this.loadContentFields();
+ },
+
+ loadContentFields() {
+ if (!this.contentInfo) return;
+ this.template_kurzbz = this.contentInfo.template_kurzbz || '';
+ this.oe_kurzbz = this.contentInfo.oe_kurzbz || '';
+ this.aktiv = !!this.contentInfo.aktiv;
+ this.menu_open = !!this.contentInfo.menu_open;
+ this.beschreibung = this.contentInfo.beschreibung || '';
+ },
+
+ loadLookups() {
+ this.$api
+ .call(ApiCmsAdmin.getTemplates())
+ .then(result => { this.templates = result.data || []; });
+ this.$api
+ .call(ApiCmsAdmin.getOrganisationseinheiten())
+ .then(result => { this.organisationseinheiten = result.data || []; });
+ },
+
+ onTemplateChange() {
+ this.templateChanged = true;
+ },
+
+ oeLabel(oe) {
+ let label = oe.organisationseinheittyp_kurzbz + ' ' + oe.bezeichnung;
+ if (!oe.aktiv) label += ' (' + this.$p.t('cms/inaktiv') + ')';
+ return label;
+ },
+
+ save() {
+ this.saving = true;
+ this.$api
+ .call(ApiCmsAdmin.putProperties(
+ this.contentId, this.sprache, this.version,
+ this.template_kurzbz, this.oe_kurzbz,
+ this.aktiv, this.menu_open, this.beschreibung,
+ this.titel, this.sichtbar
+ ))
+ .then(() => {
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
+ this.$emit('reload-content-info');
+ // Title, active flag and organisational unit all show in the tree.
+ this.$emit('refresh-tree-node', this.contentId);
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.saving = false; });
+ }
+ },
+ mounted() {
+ this.loadLookups();
+ this.loadData();
+ },
+ template: `
+
+
{{ $p.t('cms/giltFuerAlleSprachen') }}
+
+
{{ $p.t('cms/vorlage') }}
+
+
+ {{ t.bezeichnung }}
+
+
+ {{ $p.t('cms/vorlageWarnung') }}
+
+
+
+
+
{{ $p.t('cms/organisationseinheit') }}
+
+
+ {{ oeLabel(oe) }}
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/aktiv') }}
+
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/menuOpen') }}
+
+
+
+
+
+
{{ $p.t('cms/beschreibung') }}
+
+
+
+
+
+
+
+
{{ $p.t('cms/giltFuerDieseVersion') }}
+
+
{{ $p.t('cms/titel') }}
+
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/sichtbar') }}
+
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/speichern') }}
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tree/ContentTree.js b/public/js/components/Cms/Tree/ContentTree.js
new file mode 100644
index 000000000..5d8e8c920
--- /dev/null
+++ b/public/js/components/Cms/Tree/ContentTree.js
@@ -0,0 +1,521 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+import TreeFilter, { emptyCriteria } from './TreeFilter.js';
+import MenuSwitch from './MenuSwitch.js';
+
+// The meta dates are SQL timestamps. Format them like the history tab does.
+function formatDay(value) {
+ if (!value) return '';
+ const dt = luxon.DateTime.fromSQL(value);
+ return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value;
+}
+
+// A day string of the meta value. The inputs deliver YYYY-MM-DD, so compare as text.
+function inRange(value, from, to) {
+ if (!from && !to) return true;
+ if (!value) return false;
+ const day = value.substring(0, 10);
+ if (from && day < from) return false;
+ if (to && day > to) return false;
+ return true;
+}
+
+// The meta fields that hold a number. The rest hold an SQL timestamp.
+const NUMERIC_FIELDS = ['childcount', 'contentlength'];
+
+// Sort key of one node. A content without meta sorts last in a descending list.
+function sortValue(meta, contentId, field) {
+ const numeric = NUMERIC_FIELDS.indexOf(field) !== -1;
+ const m = meta[contentId];
+ if (!m) return numeric ? -1 : '';
+ if (numeric) return m[field];
+ return m[field] || '';
+}
+
+const TreeNode = {
+ name: 'CmsTreeNode',
+ inject: ['treeSelect', 'treeToggle'],
+ props: {
+ node: Object,
+ activeContentId: Number,
+ expandedKeys: Object,
+ flat: Boolean,
+ meta: Object,
+ metric: String,
+ // Ancestors of this node, from the top down. Only the nodes of the flat result
+ // list get one; a child inside the subtree sits under its parent already.
+ ancestors: Array,
+ // A node that is context rather than a hit. It and its children draw back.
+ muted: Boolean
+ },
+ computed: {
+ // Shows the value the active filter or sort works on, so the order is readable.
+ badge() {
+ if (!this.metric || !this.meta) return null;
+ const m = this.meta[this.node.content_id];
+ if (!m) return null;
+ if (NUMERIC_FIELDS.indexOf(this.metric) !== -1) return String(m[this.metric]);
+ return formatDay(m[this.metric]) || null;
+ },
+ badgeEmpty() {
+ return NUMERIC_FIELDS.indexOf(this.metric) !== -1 && this.badge === '0';
+ },
+ hasChildren() {
+ return this.node.children && this.node.children.length > 0;
+ },
+ isExpanded() {
+ return !!this.expandedKeys[this.node.content_id];
+ },
+ isActive() {
+ return this.activeContentId != null
+ && this.node.content_id === this.activeContentId;
+ },
+ // The hit keeps its place; the path above it steps in one level at a time.
+ indent() {
+ return this.ancestors ? this.ancestors.length : 0;
+ },
+ labelStyle() {
+ const s = {};
+ if (this.isActive) {
+ s.fontWeight = 'bold';
+ s.background = '#e8f0fe';
+ s.borderRadius = '3px';
+ s.padding = '0 4px';
+ }
+ if (!this.node.aktiv) {
+ s.opacity = '0.5';
+ }
+ return s;
+ }
+ },
+ template: `
+
+
+
+
+ `
+};
+
+export default {
+ name: 'CmsTree',
+ components: {
+ 'cms-tree-node': TreeNode,
+ 'tree-filter': TreeFilter,
+ 'menu-switch': MenuSwitch
+ },
+ props: {
+ activeContentId: Number
+ },
+ emits: ['select-content'],
+ data() {
+ const stored = localStorage.getItem('cms/menu');
+ return {
+ menu: (stored === 'content' || stored === 'news') ? stored : 'content',
+ filter: '',
+ nodes: [],
+ loading: false,
+ expandedKeys: {},
+ criteria: emptyCriteria(),
+ meta: null,
+ metaLoading: false,
+ // A flat list drops the context a generic title needs. The toggle puts the
+ // way down to each hit back, and the choice survives a page switch.
+ showHierarchy: localStorage.getItem('cms/treeHierarchie') === '1',
+ ancestors: {},
+ ancestorsLoading: false
+ };
+ },
+ computed: {
+ // The news menu is a flat list without the meta fields.
+ advanced() {
+ return this.menu === 'content';
+ },
+ // Every field but the template comes from the meta request.
+ needsMeta() {
+ const c = this.criteria;
+ return c.mine
+ || !!c.updatedFrom || !!c.updatedTo
+ || !!c.createdFrom || !!c.createdTo
+ || c.minChildren !== '' || c.maxChildren !== ''
+ || c.minLength !== '' || c.maxLength !== '' || c.emptyOnly
+ || c.sort === 'updateamum' || c.sort === 'insertamum'
+ || c.sort === 'childcount' || c.sort === 'contentlength';
+ },
+ narrowing() {
+ const c = this.criteria;
+ return !!c.template_kurzbz || c.mine
+ || !!c.updatedFrom || !!c.updatedTo
+ || !!c.createdFrom || !!c.createdTo
+ || c.minChildren !== '' || c.maxChildren !== ''
+ || c.minLength !== '' || c.maxLength !== '' || c.emptyOnly;
+ },
+ active() {
+ return this.advanced && (this.narrowing || this.criteria.sort !== '');
+ },
+ // A hierarchical tree already shows where a node sits. Only a flat list, from a
+ // search or from a filter, needs the path spelled out.
+ isFlat() {
+ return this.active || this.menu === 'news' || !!this.filter;
+ },
+ showPaths() {
+ return this.showHierarchy && this.isFlat;
+ },
+ // The value the badge shows. The filter drives it, the sort order falls back.
+ metric() {
+ const c = this.criteria;
+ if (!this.active) return null;
+ if (c.emptyOnly || c.minLength !== '' || c.maxLength !== '') return 'contentlength';
+ if (c.minChildren !== '' || c.maxChildren !== '') return 'childcount';
+ if (c.updatedFrom || c.updatedTo) return 'updateamum';
+ if (c.createdFrom || c.createdTo) return 'insertamum';
+ if (c.sort !== '' && c.sort !== 'titel') return c.sort;
+ return null;
+ },
+ // Flat on purpose: a match sits at any depth, so per-level sorting would hide it.
+ visibleNodes() {
+ if (!this.active) return this.nodes;
+
+ const flatList = [];
+ const walk = (nodes) => {
+ for (const node of nodes) {
+ flatList.push(node);
+ if (node.children && node.children.length) walk(node.children);
+ }
+ };
+ walk(this.nodes);
+
+ return this.sortNodes(flatList.filter(node => this.matches(node)));
+ }
+ },
+ provide() {
+ return {
+ treeSelect: (node) => { this.selectNode(node); },
+ treeToggle: (node) => { this.toggleNode(node); }
+ };
+ },
+ watch: {
+ menu() {
+ this.loadTree();
+ },
+ activeContentId(id) {
+ if (id != null) {
+ this.expandPathTo(id);
+ }
+ }
+ },
+ methods: {
+ // Keeps the expanded branches. A reload must not fold the tree back up.
+ loadTree() {
+ this.loading = true;
+ return this.$api
+ .call(ApiCmsAdmin.getTree(this.menu, this.filter))
+ .then(result => {
+ this.nodes = result.data;
+ if (this.activeContentId != null) {
+ this.expandPathTo(this.activeContentId);
+ }
+ })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.loading = false; });
+ },
+
+ // --- Public: the parent calls these instead of rebuilding the component ---
+
+ reload() {
+ // A change moves a date or a child count. Drop the cached meta.
+ this.meta = null;
+ if (this.needsMeta) this.loadMeta();
+ return this.loadTree();
+ },
+
+ // Replaces one branch. Use it after a change to the children, the title, the
+ // active flag or the organisational unit of that content.
+ refreshNode(contentId) {
+ // The news menu is a flat list of its own. Only a full reload fits it.
+ if (this.menu === 'news' || contentId == null) return this.loadTree();
+
+ const slot = this.findSlot(this.nodes, contentId);
+ // Not in the tree, so the change may add a new root. Rebuild.
+ if (slot === null) return this.loadTree();
+
+ return this.$api
+ .call(ApiCmsAdmin.getSubtree(contentId))
+ .then(result => {
+ if (result.data) {
+ slot.list.splice(slot.index, 1, result.data);
+ } else {
+ slot.list.splice(slot.index, 1);
+ }
+ if (this.activeContentId != null) {
+ this.expandPathTo(this.activeContentId);
+ }
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+
+ // Writes known fields straight into one node. Groups do not change the structure,
+ // so they need no request.
+ patchNode(contentId, patch) {
+ const slot = this.findSlot(this.nodes, contentId);
+ if (slot === null) return;
+ Object.assign(slot.node, patch);
+ },
+
+ findSlot(nodes, contentId) {
+ for (let i = 0; i < nodes.length; i++) {
+ if (nodes[i].content_id === contentId) {
+ return { list: nodes, index: i, node: nodes[i] };
+ }
+ if (nodes[i].children && nodes[i].children.length) {
+ const found = this.findSlot(nodes[i].children, contentId);
+ if (found !== null) return found;
+ }
+ }
+ return null;
+ },
+ expandPathTo(targetId) {
+ const search = (nodes) => {
+ for (const node of nodes) {
+ if (node.content_id === targetId) return true;
+ if (node.children && node.children.length) {
+ if (search(node.children)) {
+ this.expandedKeys[node.content_id] = true;
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+ search(this.nodes);
+ },
+ toggleNode(node) {
+ if (this.expandedKeys[node.content_id]) {
+ delete this.expandedKeys[node.content_id];
+ } else {
+ this.expandedKeys[node.content_id] = true;
+ }
+ },
+ selectNode(node) {
+ if (node.entitled) {
+ this.$emit('select-content', node.content_id);
+ }
+ },
+ matches(node) {
+ const c = this.criteria;
+
+ if (c.template_kurzbz && node.template_kurzbz !== c.template_kurzbz) return false;
+ if (!this.needsMeta) return true;
+ // The meta arrives after the tree. Hide nothing until it is there.
+ if (this.meta === null) return true;
+
+ const m = this.meta[node.content_id];
+ if (!m) return false;
+
+ if (c.mine && !m.mine) return false;
+ if (!inRange(m.updateamum, c.updatedFrom, c.updatedTo)) return false;
+ if (!inRange(m.insertamum, c.createdFrom, c.createdTo)) return false;
+ if (c.minChildren !== '' && m.childcount < Number(c.minChildren)) return false;
+ if (c.maxChildren !== '' && m.childcount > Number(c.maxChildren)) return false;
+ if (c.emptyOnly && m.contentlength !== 0) return false;
+ if (c.minLength !== '' && m.contentlength < Number(c.minLength)) return false;
+ if (c.maxLength !== '' && m.contentlength > Number(c.maxLength)) return false;
+
+ return true;
+ },
+
+ // Descending for the numbers and the dates: the largest and the newest come first.
+ sortNodes(list) {
+ const sort = this.criteria.sort;
+ if (!sort) return list;
+
+ if (sort === 'titel') {
+ return list.slice().sort(
+ (a, b) => (a.titel || '').localeCompare(b.titel || '')
+ );
+ }
+
+ const meta = this.meta || {};
+ return list.slice().sort((a, b) => {
+ const va = sortValue(meta, a.content_id, sort);
+ const vb = sortValue(meta, b.content_id, sort);
+ if (va === vb) return 0;
+ return va < vb ? 1 : -1;
+ });
+ },
+
+ // One request for the whole tree. Cache it, the filter changes often.
+ loadMeta() {
+ if (this.meta !== null || this.metaLoading) return;
+
+ this.metaLoading = true;
+ this.$api
+ .call(ApiCmsAdmin.getTreeMeta())
+ .then(result => { this.meta = result.data || {}; })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.metaLoading = false; });
+ },
+
+ toggleHierarchy() {
+ this.showHierarchy = !this.showHierarchy;
+ localStorage.setItem('cms/treeHierarchie', this.showHierarchy ? '1' : '0');
+
+ if (!this.showHierarchy) return;
+
+ // The point of the toggle is the context around a hit, and the subtree is half
+ // of it. A collapsed node would show the way down to the hit and nothing under
+ // it. Opening the hits leaves every caret free to collapse again.
+ for (const node of this.visibleNodes)
+ this.expandedKeys[node.content_id] = true;
+
+ this.loadAncestors();
+ },
+
+ // One request for the whole list. Asking per node would be a request per hit.
+ loadAncestors() {
+ if (!this.showPaths) return;
+
+ const ids = this.visibleNodes.map(node => node.content_id);
+ if (!ids.length) {
+ this.ancestors = {};
+ return;
+ }
+
+ this.ancestorsLoading = true;
+ this.$api
+ .call(ApiCmsAdmin.getAncestors(ids))
+ .then(result => { this.ancestors = result.data || {}; })
+ .catch(this.$fhcAlert.handleSystemError)
+ .finally(() => { this.ancestorsLoading = false; });
+ },
+
+ pathFor(contentId) {
+ return this.showPaths ? (this.ancestors[contentId] || []) : null;
+ },
+
+ onCriteria(criteria) {
+ this.criteria = criteria;
+ if (this.needsMeta) this.loadMeta();
+ this.loadAncestors();
+ },
+
+ onFilter(text) {
+ this.filter = text;
+ this.loadTree().then(() => { this.loadAncestors(); });
+ },
+ createEntry(parentId) {
+ this.$api
+ .call(ApiCmsAdmin.postContent(parentId))
+ .then(result => {
+ const newId = result.data.content_id;
+ this.$fhcAlert.alertSuccess(this.$p.t('cms/eintragAngelegt'));
+ // A new root is not under any branch, so only a reload shows it.
+ const refreshed = parentId == null
+ ? this.loadTree()
+ : this.refreshNode(parentId);
+ // Select after the node exists, or expandPathTo finds nothing.
+ return refreshed.then(() => {
+ this.$emit('select-content', newId);
+ });
+ })
+ .catch(this.$fhcAlert.handleSystemError);
+ }
+ },
+ mounted() {
+ this.loadTree();
+ },
+ template: `
+
+
+
+
+
+ {{ $p.t('cms/neuenEintragHinzufuegen') }}
+
+
+ {{ $p.t('cms/neuenChildEintragHinzufuegen') }}
+
+
+
+
+
+ {{ $p.t('cms/nTreffer', [visibleNodes.length]) }}
+
+
+
+ {{ $p.t('cms/hierarchieAnzeigen') }}
+
+
+
+
+
+
+ {{ (filter || active) ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
+
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tree/MenuSwitch.js b/public/js/components/Cms/Tree/MenuSwitch.js
new file mode 100644
index 000000000..ae31f2be9
--- /dev/null
+++ b/public/js/components/Cms/Tree/MenuSwitch.js
@@ -0,0 +1,35 @@
+export default {
+ name: 'CmsMenuSwitch',
+ props: {
+ modelValue: { type: String, default: 'content' }
+ },
+ emits: ['update:modelValue'],
+ methods: {
+ select(value) {
+ localStorage.setItem('cms/menu', value);
+ this.$emit('update:modelValue', value);
+ }
+ },
+ mounted() {
+ // The legacy code keeps this choice in $_SESSION['cms/menu']. The Vue client uses
+ // localStorage. The effect for the editor is the same.
+ const stored = localStorage.getItem('cms/menu');
+ if ((stored === 'content' || stored === 'news') && stored !== this.modelValue) {
+ this.$emit('update:modelValue', stored);
+ }
+ },
+ template: `
+
+
+ {{ $p.t('cms/menuContent') }}
+
+
+ {{ $p.t('cms/menuNews') }}
+
+
+ `
+};
diff --git a/public/js/components/Cms/Tree/TreeFilter.js b/public/js/components/Cms/Tree/TreeFilter.js
new file mode 100644
index 000000000..93599cabb
--- /dev/null
+++ b/public/js/components/Cms/Tree/TreeFilter.js
@@ -0,0 +1,209 @@
+import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
+
+// Empty means "do not narrow by this field". Keep the inputs as strings, the tree parses.
+function emptyCriteria() {
+ return {
+ template_kurzbz: '',
+ mine: false,
+ updatedFrom: '',
+ updatedTo: '',
+ createdFrom: '',
+ createdTo: '',
+ minChildren: '',
+ maxChildren: '',
+ minLength: '',
+ maxLength: '',
+ emptyOnly: false,
+ sort: ''
+ };
+}
+
+export { emptyCriteria };
+
+export default {
+ name: 'CmsTreeFilter',
+ props: {
+ // The news menu is a flat list of its own. It carries none of these fields.
+ advanced: { type: Boolean, default: true }
+ },
+ emits: ['filter', 'criteria'],
+ data() {
+ return {
+ text: '',
+ open: false,
+ templates: [],
+ criteria: emptyCriteria()
+ };
+ },
+ computed: {
+ // Counts the narrowing fields only. The sort order hides no entry.
+ activeCount() {
+ const c = this.criteria;
+ let count = 0;
+ if (c.template_kurzbz) count++;
+ if (c.mine) count++;
+ if (c.updatedFrom || c.updatedTo) count++;
+ if (c.createdFrom || c.createdTo) count++;
+ if (c.minChildren !== '' || c.maxChildren !== '') count++;
+ if (c.minLength !== '' || c.maxLength !== '') count++;
+ if (c.emptyOnly) count++;
+ return count;
+ }
+ },
+ methods: {
+ submit() {
+ this.$emit('filter', this.text);
+ },
+ toggle() {
+ this.open = !this.open;
+ if (this.open && !this.templates.length) this.loadTemplates();
+ },
+ loadTemplates() {
+ this.$api
+ .call(ApiCmsAdmin.getTemplates())
+ .then(result => { this.templates = result.data || []; })
+ .catch(this.$fhcAlert.handleSystemError);
+ },
+ reset() {
+ this.text = '';
+ // The watcher emits the cleared criteria.
+ this.criteria = emptyCriteria();
+ this.$emit('filter', '');
+ }
+ },
+ watch: {
+ // v-model writes before the watcher runs, so the emitted object is complete.
+ criteria: {
+ handler(criteria) { this.$emit('criteria', { ...criteria }); },
+ deep: true
+ }
+ },
+ created() {
+ this.sortOptions = [
+ { value: '', phrase: 'cms/sortHierarchie' },
+ { value: 'titel', phrase: 'cms/titel' },
+ { value: 'updateamum', phrase: 'cms/sortGeaendert' },
+ { value: 'insertamum', phrase: 'cms/sortErstellt' },
+ { value: 'childcount', phrase: 'cms/sortChilds' },
+ { value: 'contentlength', phrase: 'cms/sortInhaltslaenge' }
+ ];
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/filterErweitert') }}
+ {{ activeCount }}
+
+
+
+
+ {{ $p.t('cms/vorlage') }}
+
+ {{ $p.t('cms/alleVorlagen') }}
+ {{ t.bezeichnung }}
+
+
+
+
+
+
+ {{ $p.t('cms/nurEigeneBearbeitungen') }}
+
+
+
+
+
+
+ {{ $p.t('cms/nurLeereInhalte') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $p.t('cms/sortierung') }}
+
+
+ {{ $p.t(o.phrase) }}
+
+
+
+
+
+ {{ $p.t('cms/filterZuruecksetzen') }}
+
+
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/ContentchildMenu.js b/public/js/components/Contentcomponents/ContentchildMenu.js
new file mode 100644
index 000000000..90a84d60e
--- /dev/null
+++ b/public/js/components/Contentcomponents/ContentchildMenu.js
@@ -0,0 +1,84 @@
+import ApiCms from '../../api/factory/cms.js';
+
+/**
+ * Content component: lists the child pages of a content as a menu.
+ *
+ * It replaces a hand written box of content.php links. Those links carry a fixed
+ * content_id and break when a page moves. This list follows tbl_contentchild, which the
+ * Kindelemente tab of the CMS admin already maintains.
+ *
+ * Without the content-id property the menu shows the children of the page it sits on.
+ * With it, the menu shows the children of another page. The second form exists because
+ * the same menu appears on several pages today.
+ */
+export default {
+ name: 'ContentchildMenu',
+ inject: {
+ contentcomponentContentId: { default: null }
+ },
+ props: {
+ contentId: {
+ type: [Number, String],
+ default: null
+ }
+ },
+ data() {
+ return {
+ kinder: [],
+ loading: true,
+ failed: false
+ };
+ },
+ computed: {
+ sprache() {
+ return this.$p.user_language.value;
+ },
+ // The property wins. Without it the menu takes the page it sits on.
+ // Vue unwraps an injected ref on the instance, so no .value here.
+ quelle() {
+ return this.contentId || this.contentcomponentContentId;
+ }
+ },
+ methods: {
+ load() {
+ if (!this.quelle)
+ {
+ this.loading = false;
+ return;
+ }
+
+ this.loading = true;
+ this.failed = false;
+ this.$api
+ .call(ApiCms.getContentChilds(this.quelle, this.sprache))
+ .then(res => { this.kinder = res.data || []; })
+ .catch(() => { this.failed = true; })
+ .finally(() => { this.loading = false; });
+ }
+ },
+ watch: {
+ quelle() { this.load(); },
+ // The titles come per language, so a language switch needs a new request.
+ sprache() { this.load(); }
+ },
+ created() {
+ this.load();
+ },
+ template: /*html*/ `
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/ContentcomponentHost.js b/public/js/components/Contentcomponents/ContentcomponentHost.js
new file mode 100644
index 000000000..ccd676cba
--- /dev/null
+++ b/public/js/components/Contentcomponents/ContentcomponentHost.js
@@ -0,0 +1,78 @@
+import { components } from './components.js';
+import { scanContentcomponents } from './scan.js';
+
+/**
+ * Renders CMS content HTML and mounts the content components it declares.
+ *
+ * Use it in place of a plain `
`. It owns the root element, the
+ * change watch and the Teleports, so a host site needs no ref and no lifecycle code.
+ *
+ * Teleport, not a separate createApp: the components stay in this app's component tree.
+ * They therefore share the one $api instance, the one $p instance, the router and the
+ * provided keys. A createApp per marker would build a second of each.
+ */
+export default {
+ name: 'ContentcomponentHost',
+ props: {
+ content: {
+ type: String,
+ default: ''
+ },
+ // The page this content belongs to. contentchild-menu needs it when the editor
+ // gave no explicit id.
+ contentId: {
+ type: [Number, String],
+ default: null
+ }
+ },
+ provide() {
+ // Vue.computed keeps the value live, the same way Cis.js provides its keys.
+ return {
+ contentcomponentContentId: Vue.computed(() => this.contentId)
+ };
+ },
+ data() {
+ return {
+ markers: []
+ };
+ },
+ methods: {
+ rescan() {
+ this.markers = scanContentcomponents(this.$refs.root);
+ },
+ resolve(name) {
+ return components[name];
+ }
+ },
+ watch: {
+ // Vue replaces the whole v-html subtree when the string changes. A marker that
+ // still points at a removed node makes Teleport write into a detached element,
+ // and the component vanishes without an error. Drop the markers first, let the
+ // new HTML render, then scan again.
+ content() {
+ this.markers = [];
+ this.$nextTick(this.rescan);
+ }
+ },
+ // A failing content component must not take down the content around it.
+ errorCaptured(err, instance, info) {
+ console.error('Contentcomponent failed:', info, err);
+ return false;
+ },
+ mounted() {
+ // The components below carry their texts in category cms. $p.t would fetch it by
+ // itself on the first miss and fill in when it arrives, which shows an empty line
+ // for that moment. One request here from the single gateway spares that.
+ this.$p.loadCategory('cms');
+ this.rescan();
+ },
+ beforeUnmount() {
+ this.markers = [];
+ },
+ template: /*html*/ `
+
+
+
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/DmsDokumente.js b/public/js/components/Contentcomponents/DmsDokumente.js
new file mode 100644
index 000000000..fd7d0a0c9
--- /dev/null
+++ b/public/js/components/Contentcomponents/DmsDokumente.js
@@ -0,0 +1,56 @@
+import ApiCms from '../../api/factory/cms.js';
+import DokumentListe from './DokumentListe.js';
+
+/**
+ * Content component: named DMS documents, in the order the editor gave.
+ */
+export default {
+ name: 'DmsDokumente',
+ components: {
+ DokumentListe
+ },
+ props: {
+ dmsIds: {
+ type: String,
+ required: true
+ }
+ },
+ data() {
+ return {
+ dokumente: [],
+ loading: true,
+ failed: false
+ };
+ },
+ methods: {
+ load() {
+ this.loading = true;
+ this.failed = false;
+ this.$api
+ .call(ApiCms.getDmsDokumente(this.dmsIds))
+ .then(res => { this.dokumente = res.data || []; })
+ .catch(() => { this.failed = true; })
+ .finally(() => { this.loading = false; });
+ }
+ },
+ watch: {
+ dmsIds() { this.load(); }
+ },
+ created() {
+ this.load();
+ },
+ template: /*html*/ `
+
+
...
+
+ {{ $p.t('cms/ccLadefehlerDokumente') }}
+
+
+
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/DmsListe.js b/public/js/components/Contentcomponents/DmsListe.js
new file mode 100644
index 000000000..dce416e24
--- /dev/null
+++ b/public/js/components/Contentcomponents/DmsListe.js
@@ -0,0 +1,73 @@
+import ApiCms from '../../api/factory/cms.js';
+import DokumentListe from './DokumentListe.js';
+
+/**
+ * Content component: the published documents of one DMS category.
+ *
+ * The low maintenance variant. It lists every document of the category whose newest
+ * version carries cis_suche, so a replaced or added document appears without an edit in
+ * the CMS. Use dms-dokumente when the editor must pick single files.
+ */
+export default {
+ name: 'DmsListe',
+ components: {
+ DokumentListe
+ },
+ props: {
+ kategorieKurzbz: {
+ type: String,
+ required: true
+ },
+ // 0 lists the category itself, which is what a marker without the attribute
+ // means. Higher takes that many levels of sub categories with it.
+ tiefe: {
+ type: Number,
+ default: 0
+ }
+ },
+ data() {
+ return {
+ dokumente: [],
+ zugriff: true,
+ loading: true,
+ failed: false
+ };
+ },
+ methods: {
+ load() {
+ this.loading = true;
+ this.failed = false;
+ this.$api
+ .call(ApiCms.getDmsKategorie(this.kategorieKurzbz, this.tiefe))
+ .then(res => {
+ this.zugriff = res.data.zugriff;
+ this.dokumente = res.data.dokumente || [];
+ })
+ .catch(() => { this.failed = true; })
+ .finally(() => { this.loading = false; });
+ }
+ },
+ watch: {
+ kategorieKurzbz() { this.load(); },
+ tiefe() { this.load(); }
+ },
+ created() {
+ this.load();
+ },
+ template: /*html*/ `
+
+
...
+
+ {{ $p.t('cms/ccLadefehlerDokumente') }}
+
+
+
+
+ {{ $p.t('cms/ccKeineDokumente') }}
+
+
+
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/DokumentListe.js b/public/js/components/Contentcomponents/DokumentListe.js
new file mode 100644
index 000000000..c0a5c71a1
--- /dev/null
+++ b/public/js/components/Contentcomponents/DokumentListe.js
@@ -0,0 +1,59 @@
+// Mimetype to Font Awesome 6 icon. The first match wins.
+const ICONS = [
+ [/pdf/, 'fa-file-pdf'],
+ [/word|opendocument\.text/, 'fa-file-word'],
+ [/excel|sheet|csv/, 'fa-file-excel'],
+ [/powerpoint|presentation/, 'fa-file-powerpoint'],
+ [/^image\//, 'fa-file-image'],
+ [/zip|compressed|tar|rar|7z/, 'fa-file-zipper'],
+ [/^video\//, 'fa-file-video'],
+ [/^audio\//, 'fa-file-audio'],
+ [/^text\//, 'fa-file-lines']
+];
+
+/**
+ * Renders a list of DMS documents as download links.
+ *
+ * It fetches nothing. dms-liste and dms-dokumente both fill it, so a document reads the
+ * same whether the editor named a category or single ids.
+ */
+export default {
+ name: 'DokumentListe',
+ props: {
+ dokumente: {
+ type: Array,
+ default: () => []
+ }
+ },
+ methods: {
+ icon(mimetype) {
+ const typ = (mimetype || '').toLowerCase();
+ const treffer = ICONS.find(([muster]) => muster.test(typ));
+ return treffer ? treffer[1] : 'fa-file';
+ },
+ href(dokument) {
+ return FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/dms.php?id=' + dokument.dms_id;
+ },
+ datum(wert) {
+ if (!wert) return '';
+ const teile = String(wert).slice(0, 10).split('-');
+ return teile.length === 3 ? teile[2] + '.' + teile[1] + '.' + teile[0] : '';
+ }
+ },
+ template: /*html*/ `
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/OePersonen.js b/public/js/components/Contentcomponents/OePersonen.js
new file mode 100644
index 000000000..d0c52c058
--- /dev/null
+++ b/public/js/components/Contentcomponents/OePersonen.js
@@ -0,0 +1,85 @@
+import ApiCms from '../../api/factory/cms.js';
+import PersonBlock from './PersonBlock.js';
+
+/**
+ * Content component: every person assigned to an organisation unit.
+ *
+ * The low maintenance variant. A new colleague appears without an edit in the CMS, and a
+ * leaving one disappears. The price is that the editor cannot steer who is shown. Use
+ * person-block when that control matters.
+ *
+ * The function label per person comes from tbl_benutzerfunktion.bezeichnung,
+ * and falls back to tbl_funktion.beschreibung.
+ */
+export default {
+ name: 'OePersonen',
+ components: {
+ PersonBlock
+ },
+ props: {
+ oeKurzbz: {
+ type: String,
+ required: true
+ },
+ foto: {
+ type: Boolean,
+ default: false
+ },
+ // 0 lists the unit itself, which is what a marker without the attribute means.
+ // Higher takes that many levels of sub units with it.
+ tiefe: {
+ type: Number,
+ default: 0
+ }
+ },
+ data() {
+ return {
+ personen: [],
+ loading: true,
+ failed: false
+ };
+ },
+ methods: {
+ load() {
+ this.loading = true;
+ this.failed = false;
+ this.$api
+ .call(ApiCms.getOePersonen(this.oeKurzbz, this.foto, this.tiefe))
+ .then(res => { this.personen = res.data || []; })
+ .catch(() => { this.failed = true; })
+ .finally(() => { this.loading = false; });
+ }
+ },
+ watch: {
+ oeKurzbz() { this.load(); },
+ foto() { this.load(); },
+ tiefe() { this.load(); }
+ },
+ created() {
+ this.load();
+ },
+ template: /*html*/ `
+
+
...
+
+ {{ $p.t('cms/ccLadefehlerPersonen') }}
+
+
+ {{ $p.t('cms/ccKeinePersonen') }}
+
+
+
+
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/PersonBlock.js b/public/js/components/Contentcomponents/PersonBlock.js
new file mode 100644
index 000000000..bce852f4a
--- /dev/null
+++ b/public/js/components/Contentcomponents/PersonBlock.js
@@ -0,0 +1,53 @@
+/**
+ * Renders one person the way the teambox blocks do today: name, function, phone, room,
+ * mail, and an optional photo.
+ *
+ * It fetches nothing. person-block and oe-personen both fill it, so one person reads the
+ * same whether the editor named a UID or an organisation unit.
+ */
+export default {
+ name: 'PersonBlock',
+ props: {
+ uid: String,
+ name: String,
+ funktion: String,
+ telefon: String,
+ email: String,
+ ort: String,
+ foto: String
+ },
+ computed: {
+ // tel: takes digits and a leading plus.
+ telefonLink() {
+ return this.telefon ? 'tel:' + this.telefon.replace(/[^\d+]/g, '') : null;
+ },
+ fotoQuelle() {
+ return this.foto ? 'data:image/png;base64,' + this.foto : null;
+ }
+ },
+ template: /*html*/ `
+
+
+
+
+ {{ name }}
+
+ {{ name }}
+
+
{{ funktion }}
+
+
R: {{ ort }}
+
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/PersonKontakt.js b/public/js/components/Contentcomponents/PersonKontakt.js
new file mode 100644
index 000000000..6dd6572b0
--- /dev/null
+++ b/public/js/components/Contentcomponents/PersonKontakt.js
@@ -0,0 +1,77 @@
+import ApiCms from '../../api/factory/cms.js';
+import PersonBlock from './PersonBlock.js';
+
+/**
+ * Content component: one person, named by UID.
+ *
+ * The flexible variant. The editor decides who appears and in which order by placing
+ * several markers. Use oe-personen instead when the list should follow the organizational structure.
+ *
+ * The function label comes from the marker, not from the database, because the database
+ * name does not always fit. It needs no phrase key: the CMS holds one content per
+ * language, so the German page carries the German label and the English page the English
+ * one.
+ */
+export default {
+ name: 'PersonKontakt',
+ components: {
+ PersonBlock
+ },
+ props: {
+ uid: {
+ type: String,
+ required: true
+ },
+ funktion: {
+ type: String,
+ default: ''
+ },
+ foto: {
+ type: Boolean,
+ default: false
+ }
+ },
+ data() {
+ return {
+ person: null,
+ loading: true,
+ failed: false
+ };
+ },
+ methods: {
+ load() {
+ this.loading = true;
+ this.failed = false;
+ this.$api
+ .call(ApiCms.getPerson(this.uid, this.foto))
+ .then(res => { this.person = res.data; })
+ .catch(() => { this.failed = true; })
+ .finally(() => { this.loading = false; });
+ }
+ },
+ watch: {
+ uid() { this.load(); },
+ foto() { this.load(); }
+ },
+ created() {
+ this.load();
+ },
+ template: /*html*/ `
+
+
...
+
+ {{ $p.t('cms/ccLadefehlerPerson') }}
+
+
+
+ `
+};
diff --git a/public/js/components/Contentcomponents/README.md b/public/js/components/Contentcomponents/README.md
new file mode 100644
index 000000000..83df98804
--- /dev/null
+++ b/public/js/components/Contentcomponents/README.md
@@ -0,0 +1,113 @@
+# Contentcomponents
+
+Vue components that CMS editors can place inside CMS content. The editor inserts a marker
+div, CIS4 finds it and teleports a real component into it. The component fetches its own
+data and has everything a normal component has: `$api`, `$p`, the router, the provided
+keys.
+
+## The marker
+
+```html
+
+```
+
+Editors do not write this. The **Contentcomponent** button in TinyMCE 5 opens a dialog
+built from `catalog.js`.
+
+## Add a component
+
+| File | What to add |
+|---|---|
+| `MeinBaustein.js` | the component |
+| `catalog.js` | an entry in `catalog`: name, label, props |
+| `components.js` | the import plus `'mein-baustein': MeinBaustein` |
+
+Catalog and map are two hand-written lists. `components.js` logs a mismatch on load.
+
+### The component
+
+Copy `DmsListe.js`, the shortest complete example. Requirements:
+
+- It stands alone. No parent props, no route params.
+- It fetches in `created()`, and again from a `watch` on every prop.
+- It handles loading, failure and empty.
+- Root class `fhc-contentcomponent-
`.
+- `template` string. There is no `.vue` build.
+
+To get the id of the page the marker sits on:
+
+```js
+inject: { contentcomponentContentId: { default: null } }
+```
+
+`ContentchildMenu.js` shows the full pattern.
+
+Presentational helpers belong in this folder but not in the catalog. See `PersonBlock.js`
+and `DokumentListe.js`.
+
+### The catalog entry
+
+```js
+{
+ name: 'mein-baustein', // data-fhc-component, and the key in components.js
+ labelPhrase: 'ccMeinBaustein', // phrase in category cms, shown in the picker
+ props: {
+ 'mein-wert': { type: 'string', required: true, labelPhrase: 'ccMeinWert' }
+ }
+}
+```
+
+The catalog is an allow-list. `scan.js` reads only the attributes declared here and drops
+every other one.
+
+| Prop key | Meaning |
+|---|---|
+| `type` | `string`, `number` or `boolean`. Anything else stays a string. |
+| `required` | A missing attribute makes `scan.js` skip the whole marker. The dialog does not enforce it. |
+| `label` | Field label in the dialog. It defaults to the attribute name. |
+| `source` | Fills the field from a server list: `oe`, `person`, `dmskategorie`. |
+| `picker` | Only `'dms'`. Adds category and document selects that append ids to the field. |
+
+## Gotchas
+
+- **kebab-case attribute names.** The HTML parser lowercases them, so `data-meinWert` does
+ not survive. Vue maps `mein-wert` to the prop `meinWert`.
+- **`boolean` means `"true"` or `"1"`.** Everything else is false, and the dialog never
+ writes a false into the marker.
+- **Catalog name is not the file name.** `person-block` resolves to `PersonKontakt.js`.
+- **The marker is a `div`, never a custom tag.** TinyMCE 3 in the legacy admin treats an
+ unknown tag as inline and can split it on save.
+- **20 components per content** — `CONTENTCOMPONENT_MAX` in `catalog.js`.
+- **Content is never compiled as a Vue template.** An editor cannot write expressions.
+- **News renders no components on purpose.** A list shows many contents at once, and every
+ component would start its own request.
+- A throwing component does not take the content down. The host catches it and logs it.
+
+## Server data
+
+Model, controller, factory. Follow `getDmsKategorie`.
+
+1. `application/models/content/Dms_model.php` — the query.
+2. `application/controllers/api/frontend/v1/Cms.php` — the method, plus
+ `'getMeineDaten' => self::PERM_LOGGED` in the constructor. Validate with
+ `form_validation`.
+3. `public/js/api/factory/cms.js` — the request function.
+
+A missing entitlement answers with success and an empty list, not with an error. The
+reader gets no hint that something is hidden.
+
+## A new dialog source
+
+1. `CmsAdmin.php` — the endpoint, plus `['basis/cms:r']` in the constructor.
+2. `cmsadmin.js` — the request function.
+3. `ContentcomponentDialog.js` — an entry in `SOURCES`:
+
+```js
+meineliste: {
+ request: () => ApiCmsAdmin.getMeineListe(),
+ item: e => ({ value: e.kurzbz, text: e.bezeichnung })
+}
+```
+
+4. `source: 'meineliste'` in the catalog prop.
+
diff --git a/public/js/components/Contentcomponents/catalog.js b/public/js/components/Contentcomponents/catalog.js
new file mode 100644
index 000000000..76ab6de98
--- /dev/null
+++ b/public/js/components/Contentcomponents/catalog.js
@@ -0,0 +1,132 @@
+/**
+ * Catalog of the components that may be placed inside CMS content.
+ *
+ * A component is available to CMS editors only if it has an entry here. An arbitrary
+ * project component cannot work in content: it expects props from a parent, inject keys
+ * and route params. Content supplies attribute strings only. A component listed here is
+ * written for content use: lightweight, one fetch, props chosen in the CMS.
+ *
+ * Marker convention in the content HTML:
+ *
+ *
+ *
+ * A div is used instead of a custom tag on purpose. TinyMCE 5 (CIS4 admin) and TinyMCE 3
+ * (legacy admin) both treat a div as a block. An unknown tag counts as inline and the
+ * legacy admin can split or nest it on save, which would corrupt shared content.
+ */
+
+// Class on every marker. TinyMCE uses it for noneditable and for the editor styling.
+export const CONTENTCOMPONENT_CLASS = 'fhc-contentcomponent';
+
+// Attribute holding the component name.
+export const CONTENTCOMPONENT_ATTR = 'data-fhc-component';
+
+// Upper bound per content. A paste accident must not start dozens of requests.
+export const CONTENTCOMPONENT_MAX = 20;
+
+/**
+ * One entry per available component.
+ *
+ * name value of data-fhc-component, and the key in components.js
+ * labelPhrase phrase in category cms, shown in the insert dialog
+ * props attribute name (kebab-case) => { type, required, labelPhrase, source }
+ *
+ * Attribute names must be kebab-case. The HTML parser lowercases every attribute name,
+ * so camelCase would not survive. Vue maps the kebab key back to the camelCase prop.
+ *
+ * TODO evaluate where "source" belongs. It tells the insert dialog how to fill the field.
+ * Only 'text' and 'oe' exist today. A growing list may deserve its own place.
+ */
+export const catalog = [
+ {
+ name: 'oe-personen',
+ labelPhrase: 'ccOePersonen',
+ props: {
+ 'oe-kurzbz': {
+ type: 'string',
+ required: true,
+ labelPhrase: 'ccOrganisationseinheit',
+ source: 'oe'
+ },
+ 'foto': {
+ type: 'boolean',
+ required: false,
+ labelPhrase: 'ccFotosAnzeigen'
+ },
+ 'tiefe': {
+ type: 'number',
+ required: false,
+ labelPhrase: 'ccTiefeOe'
+ }
+ }
+ },
+ {
+ name: 'person-block',
+ labelPhrase: 'ccPersonBlock',
+ props: {
+ 'uid': {
+ type: 'string',
+ required: true,
+ labelPhrase: 'ccPerson',
+ source: 'person'
+ },
+ 'funktion': {
+ type: 'string',
+ required: false,
+ labelPhrase: 'ccFunktionsbezeichnung'
+ },
+ 'foto': {
+ type: 'boolean',
+ required: false,
+ labelPhrase: 'ccFotoAnzeigen'
+ }
+ }
+ },
+ {
+ name: 'dms-dokumente',
+ labelPhrase: 'ccDmsDokumente',
+ props: {
+ 'dms-ids': {
+ type: 'string',
+ required: true,
+ labelPhrase: 'ccDmsIds',
+ // Adds a category filter and a document list to the dialog. Choosing a
+ // document appends its id to this field.
+ picker: 'dms'
+ }
+ }
+ },
+ {
+ name: 'dms-liste',
+ labelPhrase: 'ccDmsListe',
+ props: {
+ 'kategorie-kurzbz': {
+ type: 'string',
+ required: true,
+ labelPhrase: 'ccDmsKategorie',
+ source: 'dmskategorie'
+ },
+ 'tiefe': {
+ type: 'number',
+ required: false,
+ labelPhrase: 'ccTiefeDms'
+ }
+ }
+ },
+ {
+ name: 'contentchild-menu',
+ labelPhrase: 'ccContentchildMenu',
+ props: {
+ 'content-id': {
+ type: 'number',
+ required: false,
+ labelPhrase: 'ccContentId'
+ }
+ }
+ }
+];
+
+export function getDescriptor(name)
+{
+ return catalog.find(entry => entry.name === name) || null;
+}
diff --git a/public/js/components/Contentcomponents/components.js b/public/js/components/Contentcomponents/components.js
new file mode 100644
index 000000000..bca04bb2e
--- /dev/null
+++ b/public/js/components/Contentcomponents/components.js
@@ -0,0 +1,33 @@
+import { catalog } from './catalog.js';
+import OePersonen from './OePersonen.js';
+import DmsListe from './DmsListe.js';
+import DmsDokumente from './DmsDokumente.js';
+import PersonKontakt from './PersonKontakt.js';
+import ContentchildMenu from './ContentchildMenu.js';
+
+/**
+ * Component name => component.
+ *
+ * Static imports. At the current scale this is simpler than a dynamic import and it
+ * survives a rollup bundle. Switch to `(await import(file)).default`, the way
+ * Dashboard/Item.js loads a widget, once the catalog gets long.
+ */
+export const components = {
+ 'oe-personen': OePersonen,
+ 'person-block': PersonKontakt,
+ 'dms-liste': DmsListe,
+ 'dms-dokumente': DmsDokumente,
+ 'contentchild-menu': ContentchildMenu
+};
+
+// The catalog and this map are written by hand and must name the same set.
+for (const entry of catalog)
+{
+ if (!components[entry.name])
+ console.error('Contentcomponent "' + entry.name + '" is in the catalog but has no component.');
+}
+for (const name of Object.keys(components))
+{
+ if (!catalog.some(entry => entry.name === name))
+ console.error('Contentcomponent "' + name + '" has a component but no catalog entry.');
+}
diff --git a/public/js/components/Contentcomponents/scan.js b/public/js/components/Contentcomponents/scan.js
new file mode 100644
index 000000000..cf1be66f0
--- /dev/null
+++ b/public/js/components/Contentcomponents/scan.js
@@ -0,0 +1,135 @@
+import {
+ catalog,
+ getDescriptor,
+ CONTENTCOMPONENT_ATTR,
+ CONTENTCOMPONENT_MAX
+} from './catalog.js';
+
+// Runs over the lifetime of the page. Two identical markers in one content therefore get
+// two distinct keys, which Vue needs to keep their Teleports apart.
+let keyCounter = 0;
+
+// One log line per problem, not one per render.
+const reported = new Set();
+
+function report(message)
+{
+ if (reported.has(message))
+ return;
+ reported.add(message);
+ console.error('Contentcomponent: ' + message);
+}
+
+/**
+ * Turns one attribute string into the declared type. Returns undefined on a bad value.
+ */
+function coerce(raw, spec)
+{
+ switch (spec.type)
+ {
+ case 'number':
+ {
+ const value = Number(raw);
+ return (raw === '' || Number.isNaN(value)) ? undefined : value;
+ }
+ case 'boolean':
+ return raw === 'true' || raw === '1';
+ default:
+ return raw;
+ }
+}
+
+/**
+ * Reads the props of one marker. Only an attribute named in the schema is read. Every
+ * other attribute is dropped. This allow-list is the boundary between the content and
+ * the component.
+ */
+function readProps(el, descriptor)
+{
+ const props = {};
+
+ for (const [name, spec] of Object.entries(descriptor.props || {}))
+ {
+ const raw = el.getAttribute('data-' + name);
+
+ if (raw === null)
+ {
+ if (spec.required)
+ {
+ report('"' + descriptor.name + '" misses the required property "' + name + '".');
+ return null;
+ }
+ continue;
+ }
+
+ const value = coerce(raw, spec);
+ if (value === undefined)
+ {
+ report('"' + descriptor.name + '" got the value "' + raw + '" for "' + name
+ + '", which is not a ' + spec.type + '.');
+ return null;
+ }
+
+ props[name] = value;
+ }
+
+ return props;
+}
+
+/**
+ * Finds every marker below root and returns the ones that can be rendered.
+ * An unknown name or a bad property is logged and skipped, never shown to a reader.
+ *
+ * @param {Element} root
+ * @return {Array<{el: Element, key: string, name: string, props: Object}>}
+ */
+export function scanContentcomponents(root)
+{
+ if (!root)
+ return [];
+
+ const elements = Array.from(root.querySelectorAll('[' + CONTENTCOMPONENT_ATTR + ']'));
+
+ if (elements.length > CONTENTCOMPONENT_MAX)
+ {
+ report('a content holds ' + elements.length + ' components. Only the first '
+ + CONTENTCOMPONENT_MAX + ' are rendered.');
+ }
+
+ const markers = [];
+
+ for (const el of elements.slice(0, CONTENTCOMPONENT_MAX))
+ {
+ const name = el.getAttribute(CONTENTCOMPONENT_ATTR);
+ const descriptor = getDescriptor(name);
+
+ if (!descriptor)
+ {
+ report('"' + name + '" is not in the catalog. Known: '
+ + catalog.map(entry => entry.name).join(', ') + '.');
+ continue;
+ }
+
+ const props = readProps(el, descriptor);
+ if (props === null)
+ continue;
+
+ // A fresh marker gets a key and loses its inner HTML. Teleport adds its children
+ // and keeps the existing ones, so a fallback text would stay under the component.
+ // A marker that already carries a key holds a mounted component. Do not clear it.
+ if (!el.dataset.fhcComponentKey)
+ {
+ el.dataset.fhcComponentKey = String(++keyCounter);
+ el.innerHTML = '';
+ }
+
+ markers.push({
+ el: el,
+ key: el.dataset.fhcComponentKey,
+ name: name,
+ props: props
+ });
+ }
+
+ return markers;
+}
diff --git a/public/js/components/DashboardWidget/News.js b/public/js/components/DashboardWidget/News.js
index 103bc4fa1..46904ff99 100644
--- a/public/js/components/DashboardWidget/News.js
+++ b/public/js/components/DashboardWidget/News.js
@@ -256,6 +256,7 @@ export default {
diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php
index f5105a28c..7f930fc17 100644
--- a/system/phrasesupdate.php
+++ b/system/phrasesupdate.php
@@ -43726,6 +43726,406 @@ array(
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorlage',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Vorlage',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Template',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'organisationseinheit',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Organisationseinheit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Organisational unit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'aktiv',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Aktiv',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Active',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'inaktiv',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'inaktiv',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'inactive',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'menuOpen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Menü geöffnet',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Menu open',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'beschreibung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Beschreibung',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Description',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sichtbar',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sichtbar',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Visible',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'contentId',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Content-ID',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Content ID',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sprache',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sprache',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Language',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'spracheWaehlen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sprache wählen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Select language',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'contentNichtGefunden',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dieser Content wurde nicht gefunden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This content was not found.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineBerechtigung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sie haben keine Berechtigung für diesen Content.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'You have no permission for this content.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'loeschenFehlgeschlagen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Das Löschen ist fehlgeschlagen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Deleting failed.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'nachKlicksReihen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Nach Klicks reihen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Rank by clicks',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'hierarchieZeigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Hierarchie zeigen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Show hierarchy',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'klicksSeit',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Klicks seit {0}. Nur angemeldete Benutzer werden gezählt.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Clicks since {0}. Only logged in users are counted.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'zeitraum',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zeitraum',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Period',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'letzteNMonate',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Letzte {0} Monate',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Last {0} months',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gesamterZeitraum',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Gesamter Zeitraum',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Entire period',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'klicksGesamt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Klicks im gesamten Zeitraum. Nur angemeldete Benutzer werden gezählt.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Clicks in the entire period. Only logged in users are counted.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
array(
'app' => 'core',
'category' => 'ui',
@@ -62411,6 +62811,3507 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
+ //******************* CORE/cms
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabEigenschaften',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Eigenschaften',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Properties',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabInhalt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Inhalt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Content',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabRechte',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Rechte',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Permissions',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabChilds',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Childs',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Children',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabHistory',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'History',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'History',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabLoeschen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Löschen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Delete',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keinEintragGewaehlt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Wählen Sie links einen Eintrag aus.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Select an entry on the left.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'speichern',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Speichern',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Save',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'hinzufuegen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Hinzufügen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Add',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'entfernen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Entfernen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Remove',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'abbrechen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Abbrechen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Cancel',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gespeichert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Daten wurden gespeichert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The data was saved.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'menuContent',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Content',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Content',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'menuNews',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'News',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'News',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'filterPlatzhalter',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Suchtext oder Content-ID',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Search text or content ID',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'neuenEintragHinzufuegen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Neuen Eintrag hinzufügen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Add new entry',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'neuenChildEintragHinzufuegen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Neuen Child-Eintrag hinzufügen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Add new child entry',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineTreffer',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Kein Eintrag passt zum Filter.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No entry matches the filter.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineEintraege',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Es sind keine Einträge vorhanden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There are no entries.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'neuerEintrag',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Neuer Eintrag',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'New entry',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'neueVersionAnlegen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Neue Version anlegen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Create new version',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'neueVersionIstUnsichtbar',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die neue Version ist unsichtbar. Schalten Sie sie in den Eigenschaften sichtbar.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The new version is invisible. Set it visible in the properties.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'uebersetzungAnlegen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Übersetzung anlegen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Create translation',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'uebersetzungIn',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Übersetzung in',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Translation into',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'giltFuerAlleSprachen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Gilt für alle Sprachen und Versionen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Applies to all languages and versions',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'giltFuerDieseVersion',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Gilt nur für diese Sprache und Version',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Applies to this language and version only',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'feldVorlage',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Vorlage',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Template',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'feldOrganisationseinheit',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Organisationseinheit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Organisational unit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'feldAktiv',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Content aktiv',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Content active',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'feldMenuOpen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Menü offen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Menu open',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'feldBeschreibung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Beschreibung',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Description',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'feldTitel',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Titel',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Title',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'feldSichtbar',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Version sichtbar',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Version visible',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorlageWarnung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Achtung: Das Ändern der Vorlage kann zum Datenverlust des Contents führen. Ändern Sie die Vorlage nur, wenn Sie wissen, was Sie tun.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Caution: changing the template can destroy the content. Only change the template if you know what you are doing.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'zurBearbeitungSperren',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zur Bearbeitung sperren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Lock for editing',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sperreFreigeben',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sperre freigeben',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Release lock',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'freigabeErzwingen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Freigabe erzwingen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Force release',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gesperrtVonSeit',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0} hat diesen Content gesperrt. Die Sperre gilt von {1} bis {2}.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '{0} locked this content. The lock is valid from {1} until {2}.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gesperrtVonSeitAbgelaufen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0} hat diesen Content gesperrt. Die Sperre galt von {1} bis {2} und ist abgelaufen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '{0} locked this content. The lock was valid from {1} until {2} and has expired.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sperreUebernehmen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sperre übernehmen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Take over lock',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'eigeneSperre',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sie haben diesen Content gesperrt. Die Sperre gilt von {0} bis {1}.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'You locked this content. The lock is valid from {0} until {1}.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'unbekannt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'unbekannt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'unknown',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'speichernNurMitSperre',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sie halten die Sperre nicht. Sperren Sie den Eintrag, bevor Sie speichern.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'You do not hold the lock. Lock the entry before you save.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'bereitsGesperrt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Ein anderer Benutzer hält die Sperre.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Another user holds the lock.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'inhaltGespeichert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Der Inhalt wurde gespeichert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The content was saved.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorschau',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Vorschau',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Preview',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorschauInEigenemFenster',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Vorschau in eigenem Fenster öffnen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Open preview in a separate window',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'inCis4Ansehen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'In CIS4 ansehen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'View in CIS4',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'unsichtbarImLivesystem',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Unsichtbar im Livesystem',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Invisible in the live system',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'folgendeGruppenDuerfenAnsehen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Mitglieder der folgenden Gruppen dürfen die Seite ansehen:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Members of these groups may view the page:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'seiteDarfVonAllenAngezeigtWerden',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diese Seite darf von allen angezeigt werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Everyone may view this page.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'letzteGruppeEntfernenBestaetigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dies ist die letzte Gruppe. Nach dem Entfernen darf jede Person die Seite ansehen. Fortfahren?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This is the last group. After removal everyone may view the page. Continue?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gruppeBereitsZugeordnet',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Gruppe ist bereits zugeordnet.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The group is already assigned.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gruppe',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Gruppe',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Group',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'bezeichnung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Bezeichnung',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Name',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'folgendeEintraegeSindUntergeordnet',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Folgende Einträge sind diesem untergeordnet:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The following entries are subordinate to this one:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineUntereintraege',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Es sind keine Untereinträge vorhanden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There are no subordinate entries.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'zuordnungEntfernenBestaetigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Zuordnung wird entfernt. Der Content bleibt erhalten und erscheint danach als Wurzeleintrag. Fortfahren?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The assignment is removed. The content remains and then appears as a root entry. Continue?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ohneTitel',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '(ohne Titel)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '(no title)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortierung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sortierung',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Order',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'id',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'ID',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'ID',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'titel',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Titel',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Title',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'nachObenSortieren',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Nach oben sortieren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Move up',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'nachUntenSortieren',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Nach unten sortieren',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Move down',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'bereitsGanzOben',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dies ist bereits der oberste Eintrag.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This is already the top entry.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'bereitsGanzUnten',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dies ist bereits der unterste Eintrag.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This is already the bottom entry.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'versionen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Versionen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Versions',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'version',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Version',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Version',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'unsichtbar',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'unsichtbar',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'invisible',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'erstelltAmVon',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Erstellt am {0} von {1}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Created on {0} by {1}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'letzteAenderungVonAm',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Letzte Änderung am {0} von {1}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Last changed on {0} by {1}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'dieseVersionLoeschen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diese Version löschen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Delete this version',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ganzenContentLoeschen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Den gesamten Content löschen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Delete the entire content',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'versionLoeschenBestaetigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diese Version wird gelöscht. Fortfahren?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This version will be deleted. Continue?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'contentLoeschenBestaetigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Der gesamte Content wird mit {0} Sprachen und {1} Versionen gelöscht. Fortfahren?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The entire content will be deleted, with {0} languages and {1} versions. Continue?',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'letzteVersionNichtLoeschbar',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diese Version kann nicht gelöscht werden. Sie ist die letzte in der Standardsprache.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This version cannot be deleted. It is the last one in the default language.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineVerwendungGefunden',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Der Content wird nirgends referenziert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The content is not referenced anywhere.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'contentWirdVerwendetIn',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Der Content wird an folgenden Stellen verwendet:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The content is used in the following places:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'wirdMitgeloescht',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'wird mitgelöscht',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'will be deleted too',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'verhindertLoeschen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'verhindert das Löschen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'prevents deletion',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'loeschenNichtMoeglichReferenzen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Der Content kann nicht gelöscht werden. Er wird noch an anderer Stelle verwendet.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The content cannot be deleted. It is still used elsewhere.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineBerechtigungFuerDiesenEintrag',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sie haben keine Berechtigung für diesen Eintrag.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'You have no permission for this entry.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineBerechtigungFuerDieseAktion',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sie haben keine Berechtigung für diese Aktion.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'You have no permission for this action.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineBerechtigteOe',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Ihnen ist keine Organisationseinheit zugeordnet.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No organisational unit is assigned to you.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'versionNichtGefunden',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diese Version wurde nicht gefunden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This version was not found.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorlageNichtGefunden',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Vorlage wurde nicht gefunden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The template was not found.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineVorlageVorhanden',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Es ist keine Vorlage vorhanden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No template exists.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'uebersetzungExistiert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diese Übersetzung existiert bereits.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This translation already exists.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'speichernFehlgeschlagen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Das Speichern ist fehlgeschlagen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Saving failed.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'xsdUngueltig',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Das Schema der Vorlage ist ungültig.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The template schema is invalid.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'xmlUngueltig',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Der erzeugte Inhalt ist kein gültiges XML.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The generated content is not valid XML.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gruppeHinzugefuegt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Gruppe hinzugefügt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Group added',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'gruppeEntfernt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Gruppe entfernt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Group removed',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'childHinzugefuegt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Untereintrag hinzugefügt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Child entry added',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'childEntfernt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Untereintrag entfernt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Child entry removed',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortierungGespeichert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Sortierung gespeichert',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Sort order saved',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'versionGeloescht',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Version gelöscht',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Version deleted',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'contentGeloescht',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Eintrag gelöscht',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Entry deleted',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'eintragAngelegt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Neuer Eintrag angelegt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'New entry created',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'neueVersionAngelegt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Neue Version angelegt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'New version created',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'uebersetzungAnlegenIn',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Übersetzung anlegen: {0}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Create translation: {0}',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'filterErweitert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Erweiterte Filter',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Advanced filter',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'filterZuruecksetzen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Filter zurücksetzen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Reset filter',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'alleVorlagen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Alle Vorlagen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'All templates',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'nurEigeneBearbeitungen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Nur eigene Bearbeitungen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Own edits only',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vonDatum',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Von',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'From',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'bisDatum',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Bis',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'To',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'mindestens',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Mindestens',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'At least',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'hoechstens',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Höchstens',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'At most',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortHierarchie',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Hierarchie',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Hierarchy',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortGeaendert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Zuletzt geändert',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Last changed',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortErstellt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Erstellt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Created',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortChilds',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Anzahl Childs',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Number of children',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'nTreffer',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0} Treffer',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '{0} matches',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabHierarchie',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Hierarchie',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Hierarchy',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'folgendeEintraegeSindUebergeordnet',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diesem Eintrag sind folgende Einträge übergeordnet:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This entry is subordinate to the following entries:',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineUebergeordnetenEintraege',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Es gibt keinen übergeordneten Eintrag. Der Eintrag ist ein Wurzeleintrag.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There is no parent entry. The entry is a root entry.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ziehenZumSortieren',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Ziehen Sie eine Zeile an die neue Position.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Drag a row to a new position.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortierungFehlgeschlagen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Sortierung wurde nicht gespeichert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The order was not saved.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'tabKlickstatistik',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Klickstatistik',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Click statistics',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'klickstatistikDeaktiviert',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Klickstatistik ist deaktiviert.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The click statistics are switched off.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'aufrufeDiesesEintrags',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Aufrufe dieses Eintrags',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Views of this entry',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'meistAufgerufeneEintraege',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Meistaufgerufene Einträge',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Most viewed entries',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'rang',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Rang',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Rank',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'aufrufe',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Aufrufe',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Views',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keineAufrufeImZeitraum',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Im Zeitraum gibt es keine Aufrufe.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There are no views in the period.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'weitereVersionen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0} weitere Versionen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '{0} more versions',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'sortInhaltslaenge',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Inhaltslänge',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Content length',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'nurLeereInhalte',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Nur leere Inhalte',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Empty contents only',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorschauLegacy',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Alte Ansicht',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Legacy view',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorschauCis4',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'CIS4-Ansicht',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'CIS4 view',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'vorschauLaedt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die CIS4-Ansicht wird geladen.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The CIS4 view is loading.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'breiteDesktop',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Volle Breite',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Full width',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'breiteMobil',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Mobil (375 px)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Mobile (375 px)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'keinCis4Stylesheet',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Diese Vorlage hat kein eigenes CIS4-Stylesheet. Die CIS4-Ansicht zeigt den Inhalt ohne Layout. Nutzen Sie die alte Ansicht.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This template has no CIS4 stylesheet. The CIS4 view shows the content without a layout. Use the legacy view.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'contentcomponentsNurCis4',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dieser Inhalt enthält Contentcomponents. Die alte Ansicht zeigt sie nicht. Wechseln Sie zur CIS4-Ansicht.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'This content holds contentcomponents. The legacy view does not show them. Switch to the CIS4 view.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'alleKlickstatistikenLaden',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Alle Klickstatistiken laden',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Load all click statistics',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccOePersonen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Team einer Organisationseinheit (automatisch)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Team of an organisational unit (automatic)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccPersonBlock',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Einzelne Person (Kontaktblock)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Single person (contact block)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccDmsDokumente',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dokumentenliste (einzelne Dokumente)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Document list (single documents)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccDmsListe',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dokumentenliste (ganze DMS-Kategorie)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Document list (whole DMS category)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccContentchildMenu',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Menü der Kindelemente',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Menu of the child entries',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccOrganisationseinheit',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Organisationseinheit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Organisational unit',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccFotosAnzeigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Fotos anzeigen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Show photos',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccFotoAnzeigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Foto anzeigen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Show photo',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccPerson',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Person',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Person',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccFunktionsbezeichnung',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Funktionsbezeichnung (leer = aus den Personaldaten)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Function name (empty = from the staff data)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccDmsIds',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'DMS-IDs, mit Komma getrennt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'DMS IDs, separated by commas',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccDmsKategorie',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'DMS-Kategorie',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'DMS category',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccContentId',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Content-ID (leer = diese Seite)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Content ID (empty = this page)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccDialogTitel',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Contentcomponent einfügen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Insert contentcomponent',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccContentcomponent',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Contentcomponent',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Contentcomponent',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccAuswaehlen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '— auswählen —',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '— select —',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccKategorie',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Kategorie',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Category',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccDokumentHinzufuegen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Dokument hinzufügen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Add document',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccNichtInDerAuswahl',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0} (nicht in der Auswahl)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '{0} (not in the list)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccNichtInCisSuche',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => '{0} (nicht in der CIS-Suche)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => '{0} (not in the CIS search)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'weiter',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Weiter',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Next',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'uebernehmen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Übernehmen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Apply',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'formatContenttabelle',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Contenttabelle',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Content table',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'formatHauptcontent',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Hauptcontent',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Main content',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'formatMenuebox',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Menuebox',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Menu box',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'formatTeambox',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Teambox',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Team box',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'formatSchatteneffekt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Schatteneffekt Grafiken',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Shadow effect for images',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'formatTablesorter',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Tablesorter',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Table sorter',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccTiefeOe',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Untereinheiten einbeziehen (Ebenen, 0 = nur diese)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Include sub units (levels, 0 = only this one)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccTiefeDms',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Unterkategorien einbeziehen (Ebenen, 0 = nur diese)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Include sub categories (levels, 0 = only this one)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccLadefehlerMenue',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Das Menü konnte nicht geladen werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The menu could not be loaded.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccLadefehlerDokumente',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Dokumentenliste konnte nicht geladen werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The document list could not be loaded.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccKeineDokumente',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Keine Dokumente vorhanden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'There are no documents.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccLadefehlerPersonen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Personen konnten nicht geladen werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The persons could not be loaded.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccKeinePersonen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Keine Personen zugeordnet.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'No persons assigned.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'ccLadefehlerPerson',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Die Person konnte nicht geladen werden.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'The person could not be loaded.',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'hierarchieAnzeigen',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Hierarchie anzeigen',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Show hierarchy',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'cms',
+ 'phrase' => 'uebersetzungAngelegt',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Übersetzung angelegt',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Translation created',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
array(
'app' => 'core',
'category' => 'lehre',